From fb7ae2d54b5191a426a1297f726b0ab8776e33a4 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Sat, 31 Aug 2024 04:23:00 +0530 Subject: [PATCH 01/31] Add verification metadata for Jetpack Compose --- gradle/verification-metadata.xml | 1110 ++++++++++++++++++++++++++++++ 1 file changed, 1110 insertions(+) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index ff47ed91..d88cfd59 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -31,6 +31,11 @@ + + + + + @@ -44,6 +49,11 @@ + + + + + @@ -539,6 +549,9 @@ + + + @@ -2231,6 +2244,17 @@ + + + + + + + + + + + @@ -2304,6 +2328,9 @@ + + + @@ -2614,5 +2641,1088 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 9d8022234691dabb07c9e4fe03b84dc2db442926 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 00:47:32 +0530 Subject: [PATCH 02/31] Include compose with required dependencies --- app/build.gradle.kts | 32 ++++++++++++++++++++++++++++++++ build.gradle.kts | 1 + 2 files changed, 33 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 84d51b3f..c2a5e605 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,12 @@ if (useKeystoreProperties) { plugins { id("com.android.application") kotlin("android") + // The following plugin should have the same version as + // org.jetbrains.kotlin.android defined at project level + // gradle + id("org.jetbrains.kotlin.plugin.compose") version "2.0.10" + id("kotlin-parcelize") + id("org.jetbrains.kotlin.plugin.serialization") } java { @@ -82,6 +88,11 @@ android { buildFeatures { viewBinding = true buildConfig = true + + compose = true + composeOptions { + kotlinCompilerExtensionVersion = "1.5.15" + } } androidResources { @@ -104,4 +115,25 @@ dependencies { implementation("androidx.camera:camera-extensions:$cameraVersion") implementation("com.google.zxing:core:3.5.3") + + val composeBom = platform("androidx.compose:compose-bom:2024.08.00") + implementation(composeBom) + + implementation("androidx.compose.material3:material3:1.2.1") + implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material:material-icons-extended:1.7.2") + + implementation("androidx.media3:media3-ui:1.4.0") + implementation("androidx.media3:media3-exoplayer:1.4.0") + + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4") + + implementation("androidx.navigation:navigation-compose:2.8.0") + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + + implementation("me.saket.telephoto:sub-sampling-image:0.13.0") + implementation("io.coil-kt.coil3:coil-compose-core:3.0.0-alpha10") + implementation("io.coil-kt.coil3:coil-video:3.0.0-alpha10") } diff --git a/build.gradle.kts b/build.gradle.kts index 2a4259bd..3bc59fdf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") version "8.8.1" apply false id("org.jetbrains.kotlin.android") version "2.1.10" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false } allprojects { From 0f440b064e5f1adb8da26ab93aba6b1e6025880d Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 00:59:49 +0530 Subject: [PATCH 03/31] Make CapturedItem serializable and parcelize able --- .../app/grapheneos/camera/CapturedItems.kt | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt index 440e992f..138fe73b 100644 --- a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt +++ b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt @@ -1,6 +1,5 @@ package app.grapheneos.camera -import android.annotation.SuppressLint import android.content.ContentResolver import android.content.ContentUris import android.content.Context @@ -13,8 +12,12 @@ import android.provider.DocumentsContract import android.provider.MediaStore import android.util.Log import app.grapheneos.camera.CamConfig.SettingValues +import app.grapheneos.camera.ui.composable.screen.serializer.CapturedItemSerializer import app.grapheneos.camera.util.edit import kotlin.jvm.Throws +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable typealias ItemType = Int const val ITEM_TYPE_IMAGE: ItemType = 0 @@ -22,6 +25,8 @@ const val ITEM_TYPE_VIDEO: ItemType = 1 const val IMAGE_NAME_PREFIX = "IMG_" const val VIDEO_NAME_PREFIX = "VID_" +@Serializable(with = CapturedItemSerializer::class) +@Parcelize class CapturedItem( val type: ItemType, val dateString: String, @@ -45,12 +50,6 @@ class CapturedItem( return "$prefix$dateString" } - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeByte(type.toByte()) - dest.writeString(dateString) - uri.writeToParcel(dest, 0) - } - override fun hashCode(): Int { return dateString.hashCode() } @@ -62,18 +61,18 @@ class CapturedItem( return dateString == other.dateString } - companion object { - @JvmField - val CREATOR = object : Parcelable.Creator { - @SuppressLint("ParcelClassLoader") - override fun createFromParcel(source: Parcel): CapturedItem { - val type = source.readByte().toInt() - val dateString = source.readString()!! - val uri = Uri.CREATOR.createFromParcel(source) - return CapturedItem(type, dateString, uri) - } + companion object : Parceler { + override fun CapturedItem.write(dest: Parcel, flags: Int) { + dest.writeByte(type.toByte()) + dest.writeString(dateString) + uri.writeToParcel(dest, 0) + } - override fun newArray(size: Int) = arrayOfNulls(size) + override fun create(source: Parcel): CapturedItem { + val type = source.readByte().toInt() + val dateString = source.readString()!! + val uri = Uri.CREATOR.createFromParcel(source) + return CapturedItem(type, dateString, uri) } } From dd0124d7f30fca4be0f995e6bc096e6d28756b8e Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:00:18 +0530 Subject: [PATCH 04/31] Add method to attempt to delete CapturedItem --- .../java/app/grapheneos/camera/CapturedItems.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt index 138fe73b..dfb6a0d0 100644 --- a/app/src/main/java/app/grapheneos/camera/CapturedItems.kt +++ b/app/src/main/java/app/grapheneos/camera/CapturedItems.kt @@ -76,6 +76,19 @@ class CapturedItem( } } + fun delete(context: Context) : Boolean { + try { + return if (uri.authority == MediaStore.AUTHORITY) { + context.contentResolver.delete(uri, null, null) > 0 + } else { + DocumentsContract.deleteDocument(context.contentResolver, uri) + } + } catch (e : Exception) { + e.printStackTrace() + return false + } + } + override fun describeContents() = 0 } From 76e71d62c567d12846cdae5262684a1c674acaeb Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:00:46 +0530 Subject: [PATCH 05/31] Add string resources --- app/src/main/res/values/strings.xml | 32 ++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9aa6ae1e..eff3fc37 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,7 +121,7 @@ Cancel Please capture a photo/video before trying to view them. - Captured photos/videos not found + No captured photos/videos found Failed to open camera due to an OS or hardware issue. Try rebooting. Failed to load camera provider due to OS or hardware issue. Try rebooting. @@ -188,6 +188,36 @@ Uses Zero Shutter Lag (ZSL) in Latency mode for faster capture. Certain devices may have a buggy implementation for this. Unable to request for audio permission in between a recording + … + Search Images + Back + + Do you wish to delete %d items? + + Play Video + Unable to load media %s + + Select Media + Select all items + %d selected + + Delete items + Share items + + Edit a copy + Edit a copy with + Delete Media + Show media info + + Edit original image with + + Unlock device + + Select an item before performing any action + Error + + Failed to delete %d/%d items + Tap to mute audio Tap to unmute audio From 26ffeea0f72fa32d981c55b11ba2afeea6857e0a Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:04:44 +0530 Subject: [PATCH 06/31] Add dialog composables --- .../component/dialog/CommonAlertDialog.kt | 91 ++++++++++ .../component/dialog/CommonInfoDialog.kt | 81 +++++++++ .../component/dialog/FileDeletionDialog.kt | 30 ++++ .../component/dialog/MediaInfoDialog.kt | 36 ++++ .../dialog/MultipleFileDeletionDialog.kt | 18 ++ .../ui/composable/model/MediaItemDetails.kt | 155 ++++++++++++++++++ 6 files changed, 411 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonAlertDialog.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonInfoDialog.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/FileDeletionDialog.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MediaInfoDialog.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MultipleFileDeletionDialog.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/model/MediaItemDetails.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonAlertDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonAlertDialog.kt new file mode 100644 index 00000000..80b90e6d --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonAlertDialog.kt @@ -0,0 +1,91 @@ +package app.grapheneos.camera.ui.composable.component.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import app.grapheneos.camera.R + +@Composable +fun CommonAlertDialog( + titleText: String? = null, + message: String? = null, + confirmButtonText: String = stringResource(R.string.ok), + cancelButtonText: String = stringResource(R.string.cancel), + confirmationCallback: () -> Unit = {}, + dismissCallback: () -> Unit = {}, +) = Dialog( + content = { + Surface( + shape = RoundedCornerShape(24.dp), + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding( + start = 24.dp, + end = 12.dp, + top = 20.dp, + bottom = 10.dp + ) + ) { + if (titleText != null) { + Text( + text = titleText, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium, + ) + } + + if (message != null) { + Text( + modifier = Modifier.padding( + top = 12.dp, + bottom = 6.dp, + end = 12.dp, + ), + style = MaterialTheme.typography.bodyLarge, + text = message + ) + } + + Row ( + modifier = Modifier + .align(Alignment.End) + .padding(top = 4.dp) + ) { + TextButton(onClick = dismissCallback) { + Text(cancelButtonText) + } + + TextButton(onClick = { + confirmationCallback() + dismissCallback() + }) { + Text(confirmButtonText) + } + + + } + } + } + + }, + + onDismissRequest = dismissCallback +) \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonInfoDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonInfoDialog.kt new file mode 100644 index 00000000..b5e7d232 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/CommonInfoDialog.kt @@ -0,0 +1,81 @@ +package app.grapheneos.camera.ui.composable.component.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import app.grapheneos.camera.R + +@Composable +fun CommonInfoDialog( + titleText: String? = null, + message: String? = null, + dismissText: String = stringResource(R.string.ok), + dismissCallback: () -> Unit = {}, +) { + Dialog( + onDismissRequest = dismissCallback, + + content = { + Surface( + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier + .padding( + start = 24.dp, + top = 20.dp, + bottom = 8.dp + ) + ) { + if (titleText != null) { + Text( + text = titleText, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .padding(bottom = 20.dp) + ) + } + + if (message != null) { + Row ( + modifier = Modifier + .verticalScroll(rememberScrollState()) + ) { + Text( + text = message + ) + + } + } + + + + TextButton( + onClick = dismissCallback, + modifier = Modifier + .align(alignment = Alignment.End) + .padding(end = 12.dp) + ) { + Text(text = dismissText) + } + + } + } + + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/FileDeletionDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/FileDeletionDialog.kt new file mode 100644 index 00000000..3ac16b6d --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/FileDeletionDialog.kt @@ -0,0 +1,30 @@ +package app.grapheneos.camera.ui.composable.component.dialog + +import androidx.compose.runtime.Composable + +import androidx.compose.ui.res.stringResource + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.R + + +@Composable +fun FileDeletionDialog( + deletionItem : CapturedItem?, + onDeleteAction: (item: CapturedItem) -> Unit, + dismissHandler: () -> Unit +) { + if (deletionItem != null) { + CommonAlertDialog( + titleText = stringResource(id = R.string.delete_title), + message = stringResource( + id = R.string.delete_description, + deletionItem.uiName(), + ), + confirmationCallback = { + onDeleteAction(deletionItem) + }, + dismissCallback = dismissHandler, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MediaInfoDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MediaInfoDialog.kt new file mode 100644 index 00000000..ac45e128 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MediaInfoDialog.kt @@ -0,0 +1,36 @@ +package app.grapheneos.camera.ui.composable.component.dialog + +import androidx.compose.runtime.Composable + +import androidx.compose.ui.res.stringResource +import app.grapheneos.camera.R +import app.grapheneos.camera.ui.composable.model.MediaItemDetails + +@Composable +fun MediaInfoDialog( + mediaItemDetails: MediaItemDetails?, + dismissCallback : () -> Unit, +) { + if (mediaItemDetails != null) { + CommonInfoDialog( + titleText = stringResource(R.string.file_details), + message = """ + ${stringResource(R.string.file_name_generic)} + ${mediaItemDetails.fileName ?: stringResource(id = R.string.not_found_generic)} + + ${stringResource(R.string.file_path)} + ${mediaItemDetails.filePath ?: stringResource(id = R.string.not_found_generic)} + + ${stringResource(R.string.file_size)} + ${mediaItemDetails.size ?: stringResource(id = R.string.not_found_generic)} + + ${stringResource(R.string.file_created_on)} + ${mediaItemDetails.dateAdded ?: stringResource(id = R.string.not_found_generic)} + + ${stringResource(R.string.last_modified_on)} + ${mediaItemDetails.dateModified ?: stringResource(id = R.string.not_found_generic)} + """.trimIndent(), + dismissCallback = dismissCallback + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MultipleFileDeletionDialog.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MultipleFileDeletionDialog.kt new file mode 100644 index 00000000..445a1dcf --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/dialog/MultipleFileDeletionDialog.kt @@ -0,0 +1,18 @@ +package app.grapheneos.camera.ui.composable.component.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.grapheneos.camera.R + +@Composable +fun MultipleFileDeletionDialog( + deletionItemsCount: Int, + onDeleteConfirmationAction: () -> Unit, + dismissCallback: () -> Unit +) = CommonAlertDialog( + titleText = stringResource(R.string.delete_title), + confirmButtonText = stringResource(R.string.delete), + message = stringResource(R.string.multiple_deletion_message, deletionItemsCount), + confirmationCallback = onDeleteConfirmationAction, + dismissCallback = dismissCallback +) \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/model/MediaItemDetails.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/model/MediaItemDetails.kt new file mode 100644 index 00000000..9d3e3615 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/model/MediaItemDetails.kt @@ -0,0 +1,155 @@ +package app.grapheneos.camera.ui.composable.model + +import android.annotation.SuppressLint +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.provider.MediaStore.MediaColumns +import android.provider.OpenableColumns +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidxc.exifinterface.media.ExifInterface +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ITEM_TYPE_VIDEO +import app.grapheneos.camera.R +import app.grapheneos.camera.util.storageLocationToUiString +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class MediaItemDetails( + val filePath: String?, + val fileName: String?, + val size: String?, + var dateAdded: String?, + var dateModified: String?, +) { + + companion object { + val Saver = Saver, Array>( + save = { + val item = it.value ?: return@Saver arrayOf() + arrayOf(item.filePath, item.fileName, item.size, item.dateAdded, item.dateModified) + }, + restore = { + if (it.isEmpty()) return@Saver null + mutableStateOf(MediaItemDetails(it[0], it[1], it[2], it[3], it[4])) + }, + ) + + fun forCapturedItem(context: Context, item: CapturedItem) : MediaItemDetails { + + var relativePath: String? = null + var fileName: String? = null + var size: String? = null + + var dateAdded: String? = null + var dateModified: String? = null + + val projection = arrayOf(MediaColumns.RELATIVE_PATH, OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + + context.contentResolver.query(item.uri, projection, null,null)?.use { + if (it.moveToFirst()) { + fileName = it.getString(1) + + if (fileName == null) { + throw Exception("File name not found for file") + } + + relativePath = getRelativePath(context, item.uri, it.getString(0), fileName!!) + size = String.format(Locale.ROOT, "%.2f MB", (it.getLong(2) / (1000f * 1000f))) + } + } + + if (item.type == ITEM_TYPE_VIDEO) { + MediaMetadataRetriever().use { + it.setDataSource(context, item.uri) + dateAdded = convertTimeForVideo(it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)!!) + dateModified = dateAdded + + } + } else { + context.contentResolver.openInputStream(item.uri)?.use { stream -> + val eInterface = ExifInterface(stream) + + val offset = eInterface.getAttribute(ExifInterface.TAG_OFFSET_TIME) + + if (eInterface.hasAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)) { + dateAdded = convertTimeForPhoto( + eInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)!!, + offset + ) + } + + if (eInterface.hasAttribute(ExifInterface.TAG_DATETIME)) { + dateModified = convertTimeForPhoto( + eInterface.getAttribute(ExifInterface.TAG_DATETIME)!!, + offset + ) + } + } + } + + return MediaItemDetails( + relativePath, + fileName, + size, + dateAdded, + dateModified, + ) + } + + @SuppressLint("SimpleDateFormat") + fun convertTime(time: Long, showTimeZone: Boolean = true): String { + val date = Date(time) + val format = SimpleDateFormat( + if (showTimeZone) { + "yyyy-MM-dd HH:mm:ss z" + } else { + "yyyy-MM-dd HH:mm:ss" + } + ) + format.timeZone = TimeZone.getDefault() + return format.format(date) + } + + private fun convertTimeForVideo(time: String): String { + val dateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss.SSS'Z'", Locale.US) + dateFormat.timeZone = TimeZone.getTimeZone("UTC") + val parsedDate = dateFormat.parse(time) + return convertTime(parsedDate?.time ?: 0) + } + + private fun convertTimeForPhoto(time: String, offset: String? = null): String { + val timestamp = if (offset != null) { + "$time $offset" + } else { + time + } + + val dateFormat = SimpleDateFormat( + if (offset == null) { + "yyyy:MM:dd HH:mm:ss" + } else { + "yyyy:MM:dd HH:mm:ss Z" + }, Locale.US + ) + + if (offset == null) { + dateFormat.timeZone = TimeZone.getDefault() + } + val parsedDate = dateFormat.parse(timestamp) + return convertTime(parsedDate?.time ?: 0, offset != null) + } + + private fun getRelativePath(ctx: Context, uri: Uri, path: String?, fileName: String): String { + if (path == null) { + return storageLocationToUiString(ctx, uri.toString()) + } + + return "${ctx.getString(R.string.main_storage)}/$path$fileName" + } + } +} \ No newline at end of file From 01987f7dba02d1450ecbad61c9b046d0a80fb634 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:14:36 +0530 Subject: [PATCH 07/31] Add common utils and extension methods --- .../java/app/grapheneos/camera/ktx/Context.kt | 18 +++++++++++ .../main/java/app/grapheneos/camera/ktx/Dp.kt | 8 +++++ .../grapheneos/camera/ktx/LazyGridScope.kt | 13 ++++++++ .../camera/ktx/NavHostController.kt | 9 ++++++ .../camera/ktx/SnackbarHostState.kt | 20 ++++++++++++ .../grapheneos/camera/util/DateTimeUtils.kt | 31 ++++++++++++++++++ .../app/grapheneos/camera/util/MimeUtils.kt | 32 +++++++++++++++++++ 7 files changed, 131 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ktx/Context.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ktx/Dp.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ktx/LazyGridScope.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ktx/NavHostController.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt create mode 100644 app/src/main/java/app/grapheneos/camera/util/DateTimeUtils.kt create mode 100644 app/src/main/java/app/grapheneos/camera/util/MimeUtils.kt diff --git a/app/src/main/java/app/grapheneos/camera/ktx/Context.kt b/app/src/main/java/app/grapheneos/camera/ktx/Context.kt new file mode 100644 index 00000000..66ab7cc0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ktx/Context.kt @@ -0,0 +1,18 @@ +package app.grapheneos.camera.ktx + +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context + +fun Context.isDeviceLocked() : Boolean { + val keyguardManager: KeyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + return keyguardManager.isKeyguardLocked +} + +fun Context.requestDeviceUnlock() { + assert(this is Activity) { + "Please ensure that requestDeviceUnlock() is called by an activity context" + } + val keyguardManager: KeyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this as Activity, null) +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt b/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt new file mode 100644 index 00000000..701511b8 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt @@ -0,0 +1,8 @@ +package app.grapheneos.camera.ktx + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp + +@Composable +fun Dp.toPx() = with(LocalDensity.current) { this@toPx.toPx() } \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ktx/LazyGridScope.kt b/app/src/main/java/app/grapheneos/camera/ktx/LazyGridScope.kt new file mode 100644 index 00000000..0e180d5b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ktx/LazyGridScope.kt @@ -0,0 +1,13 @@ +package app.grapheneos.camera.ktx + +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable + +// header alternative in LazyGrid similar to LazyColumn +fun LazyGridScope.header( + content: @Composable LazyGridItemScope.() -> Unit +) { + item(span = { GridItemSpan(this.maxLineSpan) }, content = content) +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ktx/NavHostController.kt b/app/src/main/java/app/grapheneos/camera/ktx/NavHostController.kt new file mode 100644 index 00000000..1140f18a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ktx/NavHostController.kt @@ -0,0 +1,9 @@ +package app.grapheneos.camera.ktx + +import androidx.navigation.NavHostController + +fun NavHostController.popBackStack( + onBackStackEmpty: () -> Unit +) { + if (!popBackStack()) onBackStackEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt b/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt new file mode 100644 index 00000000..28d38704 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt @@ -0,0 +1,20 @@ +package app.grapheneos.camera.ktx + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState + +suspend fun SnackbarHostState.showOrReplaceSnackbar( + message: String, + actionLabel: String? = null, + withDismissAction: Boolean = false, + duration: SnackbarDuration = + if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite +) { + currentSnackbarData?.dismiss() + showSnackbar( + message = message, + actionLabel = actionLabel, + withDismissAction = withDismissAction, + duration = duration + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/util/DateTimeUtils.kt b/app/src/main/java/app/grapheneos/camera/util/DateTimeUtils.kt new file mode 100644 index 00000000..4a153c73 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/util/DateTimeUtils.kt @@ -0,0 +1,31 @@ +package app.grapheneos.camera.util + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +fun getHumanReadableDate( + dateString: String, + inputFormat: String = "yyyyMMdd", + outputFormat: String = "EEE, MMM dd" +) : String { + val date = LocalDate.parse( + dateString, + DateTimeFormatter.ofPattern(inputFormat) + ) + + if (date == LocalDate.now()) { + return "Today" + } + + val dayDiff = LocalDate.now().toEpochDay() - date.toEpochDay() + + if (dayDiff == 1L) { + return "Yesterday" + } + + if (dayDiff < 7) { + return date.dayOfWeek.name.lowercase().replaceFirstChar(Char::uppercaseChar) + } + + return date.format(DateTimeFormatter.ofPattern(outputFormat)) +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/util/MimeUtils.kt b/app/src/main/java/app/grapheneos/camera/util/MimeUtils.kt new file mode 100644 index 00000000..0ed084a5 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/util/MimeUtils.kt @@ -0,0 +1,32 @@ +package app.grapheneos.camera.util + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ITEM_TYPE_VIDEO + +const val VIDEO_MIME_TYPE = "video/mp4" +const val IMAGE_MIME_TYPE = "image/jpg" +const val GENERIC_MIME_TYPE = "*/*" + +fun getMimeTypeForItems(items: List) : String { + + if (items.isEmpty()) return GENERIC_MIME_TYPE + + var hasPhoto = false + var hasVideo = false + + for (item in items) { + if (item.type == ITEM_TYPE_VIDEO) { + hasVideo = true + if (hasPhoto) return GENERIC_MIME_TYPE + } else { + hasPhoto = true + if (hasVideo) return GENERIC_MIME_TYPE + } + } + + if (hasVideo) { + return VIDEO_MIME_TYPE + } else { + return IMAGE_MIME_TYPE + } +} \ No newline at end of file From 6c55a6d78599aeebf9683815d9bdc2f4cbcde065 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:16:13 +0530 Subject: [PATCH 08/31] Add theme and typography for app --- .../camera/ui/composable/theme/AppColor.kt | 38 +++++++++ .../camera/ui/composable/theme/Typography.kt | 77 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/theme/AppColor.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/theme/Typography.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/theme/AppColor.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/theme/AppColor.kt new file mode 100644 index 00000000..dcf2cfb6 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/theme/AppColor.kt @@ -0,0 +1,38 @@ +package app.grapheneos.camera.ui.composable.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import app.grapheneos.camera.R + +object AppColor { + val BackgroundColor = Color(0xff181c1f) + val AppBarColor = Color(0x77000000) +} + + + +@Composable +fun appColorScheme() : ColorScheme { + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val darkTheme = isSystemInDarkTheme() + + return when { + dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) + dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current) + darkTheme -> darkColorScheme( + primaryContainer = colorResource(R.color.system_accent1_500) + ) + else -> lightColorScheme( + primaryContainer = colorResource(R.color.system_accent1_500) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/theme/Typography.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/theme/Typography.kt new file mode 100644 index 00000000..ec0d2eae --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/theme/Typography.kt @@ -0,0 +1,77 @@ +package app.grapheneos.camera.ui.composable.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +@Composable +fun appTypography() : Typography { + + return Typography( + titleSmall = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.sp, + ), + + titleMedium = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.sp, + ), + + titleLarge = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.sp, + ), + + bodySmall = MaterialTheme.typography.bodySmall.copy( + letterSpacing = 0.sp, + ), + + bodyMedium = MaterialTheme.typography.bodyMedium.copy( + letterSpacing = 0.sp, + ), + + bodyLarge = MaterialTheme.typography.bodyLarge.copy( + letterSpacing = 0.sp, + ), + + labelSmall = MaterialTheme.typography.labelSmall.copy( + letterSpacing = 0.sp, + ), + + labelMedium = MaterialTheme.typography.labelMedium.copy( + letterSpacing = 0.sp, + ), + + labelLarge = MaterialTheme.typography.labelLarge.copy( + letterSpacing = 0.sp, + ), + + displaySmall = MaterialTheme.typography.displaySmall.copy( + letterSpacing = 0.sp, + ), + + displayMedium = MaterialTheme.typography.displayMedium.copy( + letterSpacing = 0.sp, + ), + + displayLarge = MaterialTheme.typography.displayLarge.copy( + letterSpacing = 0.sp, + ), + + headlineSmall = MaterialTheme.typography.headlineSmall.copy( + letterSpacing = 0.sp, + ), + + headlineMedium = MaterialTheme.typography.headlineMedium.copy( + letterSpacing = 0.sp, + ), + + headlineLarge = MaterialTheme.typography.headlineLarge.copy( + letterSpacing = 0.sp, + ), + ) +} + From f75e8e3e1d74a5200e25363754b9674900757344 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:22:26 +0530 Subject: [PATCH 09/31] Add media preview composables --- .../component/mediapreview/MediaPreview.kt | 108 ++++++++++++++++ .../mediapreview/MediaPreviewStateShowcase.kt | 98 ++++++++++++++ .../mediapreview/SquareMediaPreview.kt | 122 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreview.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreviewStateShowcase.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/SquareMediaPreview.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreview.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreview.kt new file mode 100644 index 00000000..eeb0fcac --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreview.kt @@ -0,0 +1,108 @@ +package app.grapheneos.camera.ui.composable.component.mediapreview + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ITEM_TYPE_VIDEO +import app.grapheneos.camera.R + +import coil3.ImageLoader + +import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.video.VideoFrameDecoder + +private const val TAG = "MediaPreview" + +@Composable +fun MediaPreview( + capturedItem: CapturedItem, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + val imageLoader = remember(context, capturedItem) { + ImageLoader.Builder(context) + .components { + if (capturedItem.type == ITEM_TYPE_VIDEO) { + add(VideoFrameDecoder.Factory()) + } + } + .build() + } + + val imageRequest = remember(capturedItem) { + ImageRequest.Builder(context) + .data(capturedItem.uri) + .build() + } + + val imagePainter = rememberAsyncImagePainter( + model = imageRequest, + imageLoader = imageLoader, + ) + + // Update image on resume + // Use case: When user edits the original image and returns back to the app + LifecycleResumeEffect(Unit) { + imagePainter.restart() + onPauseOrDispose {} + } + + MediaPreviewStateShowcase( + painter = imagePainter, + capturedItem = capturedItem, + mediaPreviewLoaderType = MediaPreviewLoaderType.THREE_DOTS, + mediaPreviewErrorType = MediaPreviewErrorType.INFO_MESSAGE + ) + + Box(contentAlignment = Alignment.Center) { + Image( + painter = imagePainter, + modifier = modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + contentDescription = stringResource(R.string.preview) + ) + + if (capturedItem.type == ITEM_TYPE_VIDEO) { + Icon( + painter = painterResource(R.drawable.play), + tint = Color.White, + contentDescription = stringResource(R.string.play_video), + modifier = modifier + .background( + color = Color(0x99000000), + shape = CircleShape, + ) + .border( + width = 0.5.dp, + color = Color(0x50ffffff), + shape = CircleShape, + ) + .requiredSize(56.dp) + .padding(12.dp) + .align(Alignment.Center) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreviewStateShowcase.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreviewStateShowcase.kt new file mode 100644 index 00000000..46175882 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/MediaPreviewStateShowcase.kt @@ -0,0 +1,98 @@ +package app.grapheneos.camera.ui.composable.component.mediapreview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.R +import coil3.compose.AsyncImagePainter + +private const val TAG = "MediaPreviewStateShowcase" + +enum class MediaPreviewLoaderType { + THREE_DOTS, + SQUARE_BOX, +} + +enum class MediaPreviewErrorType { + INFO_MESSAGE, + ERROR_ICON, +} + +@Composable +fun MediaPreviewStateShowcase( + painter: AsyncImagePainter, + capturedItem: CapturedItem, + mediaPreviewLoaderType : MediaPreviewLoaderType = MediaPreviewLoaderType.THREE_DOTS, + mediaPreviewErrorType: MediaPreviewErrorType = MediaPreviewErrorType.INFO_MESSAGE, +) { + val imageState by painter.state.collectAsStateWithLifecycle() + + if (imageState is AsyncImagePainter.State.Empty || imageState is AsyncImagePainter.State.Loading) { + + if (mediaPreviewLoaderType == MediaPreviewLoaderType.THREE_DOTS) { + Text( + text = stringResource(R.string.three_dots), + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(align = Alignment.CenterVertically), + ) + } else { + Box( + modifier = Modifier + .background(Color.Gray) + .aspectRatio(1f) + .fillMaxSize() + ) + } + + + + + } else if (imageState is AsyncImagePainter.State.Error) { + if (mediaPreviewErrorType == MediaPreviewErrorType.INFO_MESSAGE) { + Text( + text = stringResource(R.string.load_media_failure_message, capturedItem.uiName()), + color = Color.Gray, + fontSize = 20.sp, + lineHeight = 24.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(align = Alignment.CenterVertically) + ) + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .wrapContentHeight(align = Alignment.CenterVertically) + .aspectRatio(1f) + .fillMaxSize(), + ) { + Icon( + Icons.Outlined.ErrorOutline, + contentDescription = stringResource(R.string.error), + ) + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/SquareMediaPreview.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/SquareMediaPreview.kt new file mode 100644 index 00000000..d7dfa335 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/mediapreview/SquareMediaPreview.kt @@ -0,0 +1,122 @@ +package app.grapheneos.camera.ui.composable.component.mediapreview + +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.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.motion.widget.MotionScene.Transition.TransitionOnClick +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ITEM_TYPE_VIDEO +import app.grapheneos.camera.R +import app.grapheneos.camera.ktx.toPx +import coil3.ImageLoader +import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.video.VideoFrameDecoder + +private const val TAG = "SquareMediaPreview" + +val SQUARE_MEDIA_PREVIEW_SIZE = 80.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SquareMediaPreview( + capturedItem: CapturedItem, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + imageSize: Int = SQUARE_MEDIA_PREVIEW_SIZE.toPx().toInt(), +) { + val context = LocalContext.current + + val haptic = LocalHapticFeedback.current + + val imageLoader = remember(context, capturedItem) { + ImageLoader.Builder(context) + .components { + if (capturedItem.type == ITEM_TYPE_VIDEO) { + add(VideoFrameDecoder.Factory()) + } + } + .build() + } + + val imageRequest = remember(capturedItem) { + ImageRequest.Builder(context) + .data(capturedItem.uri) + .size(imageSize) + .build() + } + + val imagePainter = rememberAsyncImagePainter( + model = imageRequest, + imageLoader = imageLoader, + ) + + MediaPreviewStateShowcase( + painter = imagePainter, + capturedItem = capturedItem, + mediaPreviewLoaderType = MediaPreviewLoaderType.SQUARE_BOX, + mediaPreviewErrorType = MediaPreviewErrorType.ERROR_ICON, + ) + + Box( + modifier = Modifier.combinedClickable( + onClick = onClick, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + } + ) + ) { + Image( + painter = imagePainter, + modifier = modifier + .aspectRatio(1f) + .fillMaxSize(), + contentScale = ContentScale.Crop, + contentDescription = stringResource(R.string.preview) + ) + + if (capturedItem.type == ITEM_TYPE_VIDEO) { + Icon( + painter = painterResource(R.drawable.play), + tint = Color.White, + contentDescription = stringResource(R.string.play_video), + modifier = modifier + .background( + color = Color(0x99000000), + shape = CircleShape + ) + .border( + width = 0.5.dp, + color = Color(0x50ffffff), + shape = CircleShape + ) + .requiredSize(48.dp) + .padding(12.dp) + .align(Alignment.Center) + ) + } + } +} \ No newline at end of file From 741022ac5323708bf3405c950eba5de1e22c05c2 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:26:26 +0530 Subject: [PATCH 10/31] Add serializer and navtypes for routes --- .../camera/ui/composable/model/VideoUri.kt | 33 +++++++ .../screen/navtype/CapturedItemListNavType.kt | 31 ++++++ .../screen/navtype/CapturedItemNavType.kt | 31 ++++++ .../screen/navtype/VideoUriNavType.kt | 28 ++++++ .../serializer/CapturedItemSerializer.kt | 66 +++++++++++++ .../screen/serializer/UriSerializer.kt | 95 +++++++++++++++++++ .../serializer/VideoPlayerRouteSerializer.kt | 36 +++++++ 7 files changed, 320 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/model/VideoUri.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemListNavType.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemNavType.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/VideoUriNavType.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/CapturedItemSerializer.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/UriSerializer.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/VideoPlayerRouteSerializer.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/model/VideoUri.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/model/VideoUri.kt new file mode 100644 index 00000000..f4fc93b0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/model/VideoUri.kt @@ -0,0 +1,33 @@ +package app.grapheneos.camera.ui.composable.model + +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable + +import app.grapheneos.camera.ui.composable.screen.serializer.VideoUriSerializer + +import kotlinx.serialization.Serializable + +@Serializable(with = VideoUriSerializer::class) +data class VideoUri( + val uri: Uri +) : Parcelable { + + override fun writeToParcel(dest: Parcel, flags: Int) { + uri.writeToParcel(dest, flags) + } + + override fun describeContents(): Int = 0 + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): VideoUri { + val uri = Uri.CREATOR.createFromParcel(source) + return VideoUri(uri) + } + + override fun newArray(size: Int) = arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemListNavType.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemListNavType.kt new file mode 100644 index 00000000..b59a1ad9 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemListNavType.kt @@ -0,0 +1,31 @@ +package app.grapheneos.camera.ui.composable.screen.navtype + +import android.net.Uri +import android.os.Bundle +import androidx.core.os.BundleCompat +import androidx.navigation.NavType +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ui.composable.screen.serializer.CapturedItemSerializer +import kotlinx.serialization.json.Json + +object CapturedItemListNavType : NavType?>(isNullableAllowed = true) { + override fun get(bundle: Bundle, key: String): List? { + return BundleCompat.getParcelableArrayList(bundle, key, CapturedItem::class.java) + } + + override fun parseValue(value: String): List? { + if (value.isEmpty()) return null + return Json.decodeFromString(CapturedItemSerializer.ListSerializer, Uri.decode(value)) + } + + override fun serializeAsValue(value: List?): String { + if (value == null) return "" + return Uri.encode(Json.encodeToString(CapturedItemSerializer.ListSerializer, value)) + } + + override fun put(bundle: Bundle, key: String, value: List?) { + if (value != null) + bundle.putParcelableArrayList(key, ArrayList(value)) + } + +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemNavType.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemNavType.kt new file mode 100644 index 00000000..7603461a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/CapturedItemNavType.kt @@ -0,0 +1,31 @@ +package app.grapheneos.camera.ui.composable.screen.navtype + +import android.net.Uri +import android.os.Bundle +import androidx.core.os.BundleCompat + +import androidx.navigation.NavType +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ui.composable.screen.serializer.CapturedItemSerializer +import kotlinx.serialization.json.Json + +object CapturedItemNavType : NavType(isNullableAllowed = true) { + override fun get(bundle: Bundle, key: String): CapturedItem? { + val item = BundleCompat.getParcelable(bundle, key, CapturedItem::class.java) + return item + } + + override fun parseValue(value: String): CapturedItem? { + if (value.isEmpty()) return null + return Json.decodeFromString(CapturedItemSerializer, Uri.decode(value)) + } + + override fun serializeAsValue(value: CapturedItem?): String { + if (value == null) return "" + return Uri.encode(Json.encodeToString(CapturedItemSerializer, value)) + } + + override fun put(bundle: Bundle, key: String, value: CapturedItem?) { + bundle.putParcelable(key, value) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/VideoUriNavType.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/VideoUriNavType.kt new file mode 100644 index 00000000..5cde24f8 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/navtype/VideoUriNavType.kt @@ -0,0 +1,28 @@ +package app.grapheneos.camera.ui.composable.screen.navtype + +import android.net.Uri +import android.os.Bundle +import androidx.core.os.BundleCompat +import androidx.navigation.NavType +import app.grapheneos.camera.ui.composable.model.VideoUri +import app.grapheneos.camera.ui.composable.screen.serializer.VideoUriSerializer +import kotlinx.serialization.json.Json + +object VideoUriNavType : NavType(false) { + + override fun get(bundle: Bundle, key: String): VideoUri? { + return BundleCompat.getParcelable(bundle, key, VideoUri::class.java) + } + + override fun put(bundle: Bundle, key: String, value: VideoUri) { + bundle.putParcelable(key, value) + } + + override fun parseValue(value: String): VideoUri { + return Json.decodeFromString(VideoUriSerializer, Uri.decode(value)) + } + + override fun serializeAsValue(value: VideoUri): String { + return Uri.encode(Json.encodeToString(VideoUriSerializer, value)) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/CapturedItemSerializer.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/CapturedItemSerializer.kt new file mode 100644 index 00000000..69e48ec0 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/CapturedItemSerializer.kt @@ -0,0 +1,66 @@ +package app.grapheneos.camera.ui.composable.screen.serializer + +import android.net.Uri +import app.grapheneos.camera.CapturedItem +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure + +object CapturedItemSerializer : KSerializer { + + val ListSerializer = ListSerializer(CapturedItemSerializer) + + private const val ITEM_TYPE_ELEMENT_NAME = "ITEM_TYPE" + private const val DATE_STRING_ELEMENT_NAME = "DATE_STRING" + private const val URI_ELEMENT_NAME = "URI" + + override val descriptor: SerialDescriptor + get() = buildClassSerialDescriptor( + javaClass.name + ) { + element(ITEM_TYPE_ELEMENT_NAME) + element(DATE_STRING_ELEMENT_NAME) + element(URI_ELEMENT_NAME, UriSerializer.descriptor) + } + + override fun deserialize(decoder: Decoder): CapturedItem { + return decoder.decodeStructure( + descriptor + ) { + var itemType = 0 + var dateString = "" + var uri = Uri.EMPTY + + while(true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> itemType = decodeIntElement(descriptor, 0) + 1 -> dateString = decodeStringElement(descriptor, 1) + 2 -> uri = decodeSerializableElement(descriptor, 2, UriSerializer) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + + CapturedItem( + type = itemType, + dateString = dateString, + uri = uri + ) + } + } + + override fun serialize(encoder: Encoder, value: CapturedItem) { + encoder.encodeStructure(descriptor) { + encodeIntElement(descriptor, 0, value.type) + encodeStringElement(descriptor, 1, value.dateString) + encodeSerializableElement(descriptor, 2, UriSerializer, value.uri) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/UriSerializer.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/UriSerializer.kt new file mode 100644 index 00000000..606f7b64 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/UriSerializer.kt @@ -0,0 +1,95 @@ +package app.grapheneos.camera.ui.composable.screen.serializer + +import android.net.Uri + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure + +object UriSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = + buildClassSerialDescriptor(javaClass.name) { + element("scheme") + element("host") + element("port") + element("path") + element("query") + element("fragment") + } + + override fun deserialize(decoder: Decoder): Uri { + var scheme: String? = null + var host: String? = null + var port = -1 + var path: String? = null + var query: String? = null + var fragment: String? = null + + decoder.decodeStructure( + descriptor + ) { + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> scheme = decodeStringElement(descriptor, 0) + 1 -> host = decodeStringElement(descriptor, 1) + 2 -> port = decodeIntElement(descriptor, 2) + 3 -> path = decodeStringElement(descriptor, 3) + 4 -> query = decodeStringElement(descriptor, 4) + 5 -> fragment = decodeStringElement(descriptor, 5) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected error at $index") + } + } + } + + val uri = Uri.Builder() + .apply { + if (scheme != null) scheme(scheme) + if (host != null) { + if (port != -1) authority("$host:$port") + else authority(host) + } + if (path != null) path(path) + if (query != null) query(query) + if (fragment != null) fragment(fragment) + } + .build() + + return uri + } + + override fun serialize(encoder: Encoder, value: Uri) { + encoder.encodeStructure(descriptor) { + value.scheme?.let { + encodeStringElement(descriptor, 0, it) + } + + value.host?.let { + encodeStringElement(descriptor, 1, it) + } + + if (value.port != -1) { + encodeIntElement(descriptor, 2, value.port) + } + + value.path?.let { + encodeStringElement(descriptor, 3, it) + } + + value.query?.let { + encodeStringElement(descriptor, 4, it) + } + + value.fragment?.let { + encodeStringElement(descriptor,5, it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/VideoPlayerRouteSerializer.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/VideoPlayerRouteSerializer.kt new file mode 100644 index 00000000..32e96b5a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/serializer/VideoPlayerRouteSerializer.kt @@ -0,0 +1,36 @@ +package app.grapheneos.camera.ui.composable.screen.serializer + +import app.grapheneos.camera.ui.composable.model.VideoUri +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure + +object VideoUriSerializer : KSerializer { + + private const val VIDEO_URI_ELEMENT_NAME = "VIDEO_URI" + + override val descriptor: SerialDescriptor + get() = buildClassSerialDescriptor(javaClass.name) { + element(VIDEO_URI_ELEMENT_NAME, UriSerializer.descriptor) + } + + override fun deserialize(decoder: Decoder): VideoUri { + return decoder.decodeStructure(descriptor) { + check(decodeElementIndex(descriptor) == 0) + val videoUri = decodeSerializableElement(descriptor, 0, UriSerializer) + check(decodeElementIndex(descriptor) == CompositeDecoder.DECODE_DONE) + VideoUri(videoUri) + } + } + + override fun serialize(encoder: Encoder, value: VideoUri) { + encoder.encodeStructure(descriptor) { + encodeSerializableElement(descriptor, 0, UriSerializer, value.uri) + } + } +} \ No newline at end of file From 7cf75d83662050eaf883076454b806ae5365a45f Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:29:15 +0530 Subject: [PATCH 11/31] Add QuickTooltip composable --- .../component/tooltip/QuickTooltip.kt | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/tooltip/QuickTooltip.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/tooltip/QuickTooltip.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/tooltip/QuickTooltip.kt new file mode 100644 index 00000000..84c9c431 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/tooltip/QuickTooltip.kt @@ -0,0 +1,104 @@ +package app.grapheneos.camera.ui.composable.component.tooltip + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupPositionProvider +import app.grapheneos.camera.ktx.toPx +import kotlin.math.absoluteValue + +private const val TAG = "QuickTooltip" + +enum class QuickTooltipVerticalDirection { + TOP, + BOTTOM, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickTooltip( + message: String, + tooltipAnchorVerticalSpacing: Float = 16.dp.toPx(), + defaultDirection: QuickTooltipVerticalDirection = QuickTooltipVerticalDirection.BOTTOM, + content: @Composable () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + + val tooltipState = rememberTooltipState() + + LaunchedEffect(tooltipState.isVisible) { + if (tooltipState.isVisible) { + hapticFeedback.performHapticFeedback( + HapticFeedbackType.LongPress + ) + } + } + + TooltipBox ( + positionProvider = object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + + val anchorPopWidthDiff = anchorBounds.width - popupContentSize.width + + val xOffset = if (anchorPopWidthDiff >= 0) { + anchorBounds.left + anchorPopWidthDiff / 2 + } else if ( + (windowSize.width - anchorBounds.right >= anchorPopWidthDiff.absoluteValue / 2) + ) { + anchorBounds.left - anchorPopWidthDiff.absoluteValue / 2 + } else { + anchorBounds.right - popupContentSize.width + }.toInt() + + var yOffset : Float + + if (defaultDirection == QuickTooltipVerticalDirection.TOP) { + yOffset = anchorBounds.top - popupContentSize.height - tooltipAnchorVerticalSpacing + if (yOffset < 0) + yOffset = anchorBounds.bottom + tooltipAnchorVerticalSpacing + } else { + yOffset = anchorBounds.bottom + tooltipAnchorVerticalSpacing + } + + return IntOffset(xOffset, yOffset.toInt()) + } + }, + + tooltip = { + PlainTooltip { + Text( + message, + fontSize = 14.sp, + modifier = Modifier + .padding( + vertical = 4.dp, + horizontal = 2.dp, + ) + ) + } + + }, + state = tooltipState, + content = content + ) +} \ No newline at end of file From c5587148d97e4a890e0bf3a566e362e7571bcea1 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:31:52 +0530 Subject: [PATCH 12/31] Add generic and custom topbar composables --- .../component/topbar/TopBarActions.kt | 169 ++++++++++++++++++ .../extendedgallery/ExtendedGalleryTopBar.kt | 124 +++++++++++++ .../ExtendedGalleryTopBarActions.kt | 75 ++++++++ .../component/topbar/gallery/GalleryTopBar.kt | 107 +++++++++++ .../topbar/gallery/GalleryTopBarActions.kt | 60 +++++++ 5 files changed, 535 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/TopBarActions.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBar.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBarActions.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBar.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBarActions.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/TopBarActions.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/TopBarActions.kt new file mode 100644 index 00000000..ae98ea5f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/TopBarActions.kt @@ -0,0 +1,169 @@ +package app.grapheneos.camera.ui.composable.component.topbar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth + +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert + +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text + +import androidx.compose.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.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight + +import androidx.compose.ui.unit.IntSize + +import androidx.compose.ui.unit.dp +import app.grapheneos.camera.ktx.toPx +import app.grapheneos.camera.ui.composable.component.tooltip.QuickTooltip + +data class TopBarAction( + val id : Any, + val title: String, + val icon: ImageVector, + val alwaysInMoreOptions : Boolean = false +) + +private const val TAG = "TopBarActions" + +// 40.0.dp was taken from an constant internal to the material library +// IconButtonTokens.StateLayerSize used by IconButton's code internally +private val SPACE_PER_ICON_BUTTON = 40.dp + +@Composable +fun TopBarActions( + actions : List, + onActionClicked: (id: Any) -> Unit, + modifier: Modifier = Modifier, +) { + + var size by remember { + mutableStateOf(IntSize.Zero) + } + + var dropDownMenuExpanded by remember { + mutableStateOf(false) + } + + Box ( + modifier = Modifier + .then(modifier) + .onGloballyPositioned { + size = it.size + }, + contentAlignment = Alignment.CenterEnd + ) { + if (size == IntSize.Zero) return@Box + + val width = size.width + + // Subtract 1 for the more options icon + val maxVisibleActions = (width / SPACE_PER_ICON_BUTTON.toPx()).toInt() - 1 + + // Ensure there is space to at least have the more options icon + assert(maxVisibleActions >= 0) { + "Please ensure that TopBarActions gets a width of 40.dp at least" + } + + val visibleActions = arrayListOf() + val moreOptionsActions = arrayListOf() + + for (action in actions) { + if (action.alwaysInMoreOptions || visibleActions.size >= maxVisibleActions) { + moreOptionsActions.add(action) + } else { + visibleActions.add(action) + } + } + + Row (horizontalArrangement = Arrangement.End) { + for (visibleAction in visibleActions) { + QuickTooltip( + message = visibleAction.title + ) { + IconButton(onClick = { + onActionClicked(visibleAction.id) + }) { + Icon(visibleAction.icon, visibleAction.title) + } + } + + } + + Box { + if (moreOptionsActions.isNotEmpty()) { + QuickTooltip( + message = "More Options" + ) { + IconButton(onClick = { + dropDownMenuExpanded = !dropDownMenuExpanded + }) { + Icon( + Icons.Filled.MoreVert, + "More Options" + ) + } + } + + DropdownMenu( + expanded = dropDownMenuExpanded, + onDismissRequest = { + dropDownMenuExpanded = false + }, + modifier = Modifier + .fillMaxWidth(.5f) + .widthIn(max = 200.dp) + ) { + for (moreOptionsAction in moreOptionsActions) { + DropdownMenuItem( + text = { + Text( + text = moreOptionsAction.title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + ) + ) + }, + + leadingIcon = { + Icon( + imageVector = moreOptionsAction.icon, + contentDescription = moreOptionsAction.title, + ) + }, + + onClick = { + onActionClicked(moreOptionsAction.id) + dropDownMenuExpanded = false + }, + ) + } + } + } + } + } + + + } + +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBar.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBar.kt new file mode 100644 index 00000000..1ae92390 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBar.kt @@ -0,0 +1,124 @@ +package app.grapheneos.camera.ui.composable.component.topbar.extendedgallery + +import androidx.compose.foundation.layout.fillMaxWidth + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text + +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource + +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.R +import app.grapheneos.camera.ui.composable.component.topbar.TopBarActions + +private const val TAG = "ExtendedGalleryTopBar" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExtendedGalleryTopBar( + selectModeEnabled: Boolean, + secureMode: Boolean = false, + selectedItems: List, + onEnableSelectMode: () -> Unit = {}, + onDisableSelectMode: () -> Unit = {}, + onAllItemsSelectAction : () -> Unit = {}, + onDeleteItemsAction: () -> Unit = {}, + onSharedItemsAction: () -> Unit = {}, + onDeviceUnlockRequest: () -> Unit = {}, + onExitAction: () -> Unit = {}, +) { + Surface( + shadowElevation = 4.dp + ) { + TopAppBar( + title = { + if (selectModeEnabled) { + Text( + stringResource(R.string.selected_items_template, selectedItems.size), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Normal), + overflow = TextOverflow.Ellipsis + ) + } + }, + + colors = TopAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + navigationIconContentColor = MaterialTheme.colorScheme.onBackground, + actionIconContentColor = MaterialTheme.colorScheme.onBackground, + ), + + navigationIcon = { + if (selectModeEnabled) { + IconButton(onClick = onDisableSelectMode) { + Icon( + Icons.Default.Clear, + contentDescription = null + ) + } + } else { + IconButton(onClick = onExitAction) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + } + }, + + actions = { + TopBarActions( + actions = if (selectModeEnabled) { + selectionModeActions() + } else { + defaultActions(secureMode = secureMode) + }, + + onActionClicked = { action -> + when (action) { + ExtendedGalleryActions.SELECT_MEDIA_ACTION -> { + onEnableSelectMode() + } + + ExtendedGalleryActions.SELECT_ALL_ITEMS_ACTION -> { + onAllItemsSelectAction() + } + + ExtendedGalleryActions.SHARE_SELECTED_ITEMS_ACTION -> { + onSharedItemsAction() + } + + ExtendedGalleryActions.DELETE_SELECTED_ITEMS_ACTION -> { + onDeleteItemsAction() + } + + ExtendedGalleryActions.UNLOCK_DEVICE_ACTION -> { + onDeviceUnlockRequest() + } + } + }, + + modifier = Modifier + .fillMaxWidth(0.4f) + ) + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBarActions.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBarActions.kt new file mode 100644 index 00000000..7ec3f4bf --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/extendedgallery/ExtendedGalleryTopBarActions.kt @@ -0,0 +1,75 @@ +package app.grapheneos.camera.ui.composable.component.topbar.extendedgallery + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Share +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.media3.common.util.EGLSurfaceTexture.SecureMode +import app.grapheneos.camera.R +import app.grapheneos.camera.ktx.isDeviceLocked +import app.grapheneos.camera.ui.composable.component.topbar.TopBarAction + +private const val TAG = "ExtendedGalleryActions" + +enum class ExtendedGalleryActions { + SELECT_MEDIA_ACTION, + SHARE_SELECTED_ITEMS_ACTION, + DELETE_SELECTED_ITEMS_ACTION, + SELECT_ALL_ITEMS_ACTION, + UNLOCK_DEVICE_ACTION, +} + +@Composable +fun defaultActions( + secureMode: Boolean = false +) : List { + + return buildList { + if (secureMode) { + add( + TopBarAction( + id = ExtendedGalleryActions.UNLOCK_DEVICE_ACTION, + title = stringResource(R.string.unlock_device), + icon = Icons.Default.LockOpen + ) + ) + } + + add( + TopBarAction( + id = ExtendedGalleryActions.SELECT_MEDIA_ACTION, + title = stringResource(R.string.select_media), + icon = Icons.Default.Check, + alwaysInMoreOptions = true + ) + ) + } + + +} + +@Composable +fun selectionModeActions() : List { + return listOf( + TopBarAction( + id = ExtendedGalleryActions.SELECT_ALL_ITEMS_ACTION, + title = stringResource(R.string.select_media), + icon = Icons.Default.SelectAll, + ), + TopBarAction( + id = ExtendedGalleryActions.DELETE_SELECTED_ITEMS_ACTION, + title = stringResource(R.string.delete_items), + icon = Icons.Default.Delete, + ), + TopBarAction( + id = ExtendedGalleryActions.SHARE_SELECTED_ITEMS_ACTION, + title = stringResource(R.string.share_items), + icon = Icons.Default.Share, + ), + ) +} diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBar.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBar.kt new file mode 100644 index 00000000..b3c48e02 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBar.kt @@ -0,0 +1,107 @@ +package app.grapheneos.camera.ui.composable.component.topbar.gallery + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseIn +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.fillMaxWidth + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme + +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.runtime.Composable + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import app.grapheneos.camera.R + +import app.grapheneos.camera.ui.composable.component.topbar.TopBarActions +import app.grapheneos.camera.ui.composable.theme.AppColor + +private const val TAG = "GalleryTopBar" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GalleryTopBar( + visible: Boolean, + onCloseAction: () -> Unit, + onEditAction: (chooseApp: Boolean, modifyOriginal: Boolean) -> Unit, + onDeleteAction: () -> Unit, + onInfoAction: () -> Unit, + onShareAction: () -> Unit +) { + + AnimatedVisibility( + visible = visible, + + enter = slideInVertically( + initialOffsetY = { height -> -height }, + animationSpec = tween(durationMillis = 300, easing = EaseIn), + ), + exit = slideOutVertically( + targetOffsetY = { height -> -height }, + animationSpec = tween(durationMillis = 300, easing = EaseIn), + ), + ) { + TopAppBar( + title = {}, + + colors = TopAppBarColors( + containerColor = AppColor.AppBarColor, + scrolledContainerColor = AppColor.AppBarColor, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White, + ), + + navigationIcon = { + IconButton(onClick = onCloseAction) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + + actions = { + TopBarActions( + actions = galleryTopBarActions(), + onActionClicked = { action -> + when (action) { + GalleryActions.EDIT_MEDIA -> { + onEditAction(false, false) + } + GalleryActions.DELETE_MEDIA -> { + onDeleteAction() + } + GalleryActions.SHOW_MEDIA_INFO -> { + onInfoAction() + } + GalleryActions.SHARE_MEDIA -> { + onShareAction() + } + GalleryActions.EDIT_MEDIA_WITH_APP -> { + onEditAction(true, false) + } + GalleryActions.EDIT_MEDIA_IN_PLACE -> { + onEditAction(true, true) + } + } + }, + // To avoid overlapping leading back arrow and some spacing + modifier = Modifier + .fillMaxWidth(0.7f) + ) + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBarActions.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBarActions.kt new file mode 100644 index 00000000..da908cd7 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/topbar/gallery/GalleryTopBarActions.kt @@ -0,0 +1,60 @@ +package app.grapheneos.camera.ui.composable.component.topbar.gallery + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Share +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.grapheneos.camera.R +import app.grapheneos.camera.ui.composable.component.topbar.TopBarAction + +private const val TAG = "GalleryActions" + +enum class GalleryActions { + EDIT_MEDIA, + DELETE_MEDIA, + SHOW_MEDIA_INFO, + SHARE_MEDIA, + EDIT_MEDIA_WITH_APP, + EDIT_MEDIA_IN_PLACE, +} + +@Composable +fun galleryTopBarActions() : List { + return listOf( + TopBarAction( + id = GalleryActions.EDIT_MEDIA, + title = stringResource(R.string.edit_a_copy), + icon = Icons.Filled.Edit + ), + TopBarAction( + id = GalleryActions.DELETE_MEDIA, + title = stringResource(R.string.delete_media), + icon = Icons.Filled.Delete + ), + TopBarAction( + id = GalleryActions.SHOW_MEDIA_INFO, + title = stringResource(R.string.show_media_info), + icon = Icons.Filled.Info + ), + TopBarAction( + id = GalleryActions.SHARE_MEDIA, + title = stringResource(R.string.share_media), + icon = Icons.Filled.Share + ), + TopBarAction( + id = GalleryActions.EDIT_MEDIA_WITH_APP, + title = stringResource(R.string.edit_a_copy_with), + icon = Icons.Filled.Edit, + alwaysInMoreOptions = true + ), + TopBarAction( + id = GalleryActions.EDIT_MEDIA_IN_PLACE, + title = stringResource(R.string.edit_original_image), + icon = Icons.Filled.Edit, + alwaysInMoreOptions = true + ), + ) +} \ No newline at end of file From 6704c2f45f5e354dd8def9cd29e0206f0af1b3ff Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:33:04 +0530 Subject: [PATCH 13/31] Add compose implementation for Video Player --- .../camera/ui/activities/VideoPlayer.kt | 142 ++--------------- .../screen/routes/VideoPlayerRoute.kt | 19 +++ .../composable/screen/ui/VideoPlayerScreen.kt | 144 ++++++++++++++++++ 3 files changed, 174 insertions(+), 131 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/VideoPlayerRoute.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/VideoPlayer.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/VideoPlayer.kt index b9f94bb7..1ffc06af 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/VideoPlayer.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/VideoPlayer.kt @@ -1,27 +1,19 @@ package app.grapheneos.camera.ui.activities -import android.graphics.drawable.ColorDrawable -import android.media.AudioManager -import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Bundle -import android.util.Log -import android.widget.FrameLayout -import android.widget.MediaController -import android.widget.RelativeLayout + +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat + +import app.grapheneos.camera.ui.composable.screen.ui.VideoPlayerScreen import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.Lifecycle + import app.grapheneos.camera.AutoFinishOnSleep -import app.grapheneos.camera.R -import app.grapheneos.camera.databinding.VideoPlayerBinding -import app.grapheneos.camera.util.getParcelableExtra -import kotlin.concurrent.thread +import app.grapheneos.camera.util.getParcelableExtra class VideoPlayer : AppCompatActivity() { @@ -31,8 +23,6 @@ class VideoPlayer : AppCompatActivity() { const val VIDEO_URI = "videoUri" } - private lateinit var binding: VideoPlayerBinding - private val autoFinisher = AutoFinishOnSleep(this) private var isSecureMode = false @@ -56,123 +46,13 @@ class VideoPlayer : AppCompatActivity() { autoFinisher.start() } - binding = VideoPlayerBinding.inflate(layoutInflater) - setContentView(binding.root) - - supportActionBar?.let { - it.setBackgroundDrawable(ColorDrawable(ContextCompat.getColor(this, R.color.appbar))) - it.setDisplayShowTitleEnabled(false) - it.setDisplayHomeAsUpEnabled(true) - } - val uri = getParcelableExtra(intent, VIDEO_URI)!! - val videoView = binding.videoPlayer - - val mediaController = object : MediaController(this) { - override fun show() { - super.show() - showActionBar() - windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) - } - - override fun hide() { - super.hide() - hideActionBar() - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - } - } - - supportActionBar?.setBackgroundDrawable(null) - - ViewCompat.setOnApplyWindowInsetsListener(binding.shade) { view, insets -> - val systemBars = insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars()) - val actionBarHeight = resources.getDimensionPixelSize(R.dimen.action_bar_height) - view.layoutParams = - FrameLayout.LayoutParams( - RelativeLayout.LayoutParams.MATCH_PARENT, - systemBars.top + actionBarHeight - ) - view.background = ContextCompat.getDrawable(this@VideoPlayer, R.drawable.shade) - insets - } - - thread { - var hasAudio = true - try { - MediaMetadataRetriever().use { - it.setDataSource(this, uri) - hasAudio = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) != null - } - } catch (e: Exception) { - Log.d(TAG, "", e) - } - - mainExecutor.execute { - val lifecycleState = lifecycle.currentState - - if (lifecycleState == Lifecycle.State.DESTROYED) { - return@execute - } - - val audioFocus = if (hasAudio) AudioManager.AUDIOFOCUS_GAIN else AudioManager.AUDIOFOCUS_NONE - videoView.setAudioFocusRequest(audioFocus) - - videoView.setOnPreparedListener { _ -> - videoView.setMediaController(mediaController) - - if (lifecycleState == Lifecycle.State.RESUMED) { - videoView.start() - } - - showActionBar() - mediaController.show(0) - } - - videoView.setVideoURI(uri) - } - } - } - - override fun onSupportNavigateUp(): Boolean { - finish() - return true - } - - override fun onResume() { - super.onResume() - showActionBar() - } - - private fun hideActionBar() { - supportActionBar?.hide() - animateShadeToTransparent() - } - - private fun showActionBar() { - supportActionBar?.show() - animateShadeToOriginal() - } - - private fun animateShadeToTransparent() { - if (binding.shade.alpha == 0f) { - return - } - - binding.shade.animate().apply { - duration = 300 - alpha(0f) - } - } - - private fun animateShadeToOriginal() { - if (binding.shade.alpha == 1f) { - return - } - - binding.shade.animate().apply { - duration = 300 - alpha(1f) + setContent { + VideoPlayerScreen( + mediaUri = uri, + onExitAction = this::finish + ) } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/VideoPlayerRoute.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/VideoPlayerRoute.kt new file mode 100644 index 00000000..9c18a66a --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/VideoPlayerRoute.kt @@ -0,0 +1,19 @@ +package app.grapheneos.camera.ui.composable.screen.routes + +import app.grapheneos.camera.ui.composable.model.VideoUri +import app.grapheneos.camera.ui.composable.screen.navtype.VideoUriNavType + +import kotlin.reflect.typeOf +import kotlinx.serialization.Serializable + +@Serializable +data class VideoPlayerRoute( + val videoUri: VideoUri, +) { + companion object { + val typeMap = mapOf( + typeOf() to VideoUriNavType, + ) + } +} + diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt new file mode 100644 index 00000000..f6b3892f --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt @@ -0,0 +1,144 @@ +package app.grapheneos.camera.ui.composable.screen.ui + +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton + +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.viewinterop.AndroidView +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.Log +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import app.grapheneos.camera.R +import app.grapheneos.camera.ui.composable.theme.AppColor +import kotlinx.coroutines.launch + +private const val TAG = "VideoPlayerScreen" + +@androidx.annotation.OptIn(UnstableApi::class) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoPlayerScreen( + mediaUri: Uri, + onExitAction: () -> Unit = {}, +) { + + val context = LocalContext.current + + val coroutineScope = rememberCoroutineScope() + + val exoPlayer = remember { + ExoPlayer.Builder(context).build() + } + + // Executed once (unless the mediaUri the key here changes) + LaunchedEffect(mediaUri) { + coroutineScope.launch { + var hasAudio = true + try { + MediaMetadataRetriever().use { + it.setDataSource(context, mediaUri) + hasAudio = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) != null + } + } catch (e: Exception) { + Log.d(TAG, "Unable to retrieve HAS_AUDIO metadata for video", e) + } + + // Requests for focus when audio is enabled + if (hasAudio) { + exoPlayer.setAudioAttributes( + AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + true + ) + } + + val mediaItem = MediaItem.fromUri(mediaUri) + exoPlayer.setMediaItem(mediaItem) + + exoPlayer.prepare() + + // Auto-play only when the player first starts + exoPlayer.play() + } + + } + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + // Don't play when user is away from screen + if (exoPlayer.isPlaying) + exoPlayer.pause() + } + } + + // Run only once, a listener set which + DisposableEffect(Unit) { + onDispose { + exoPlayer.release() + } + } + + // Main UI + Scaffold ( + topBar = { + TopAppBar( + title = {}, + colors = TopAppBarColors( + containerColor = AppColor.AppBarColor, + scrolledContainerColor = AppColor.AppBarColor, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White, + ), + navigationIcon = { + IconButton(onClick = onExitAction) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) + }, + containerColor = AppColor.BackgroundColor, + content = { innerPadding -> + AndroidView( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + + factory = { context -> + PlayerView(context).apply { + player = exoPlayer + controllerShowTimeoutMs = 2000 + } + } + ) + + } + ) +} From b2e69078d0d6b89cbb60cca4b6ef9d8661eb43b6 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:35:24 +0530 Subject: [PATCH 14/31] Add CapturedItemsRepository to access and observe captured items --- .../viewmodel/CapturedItemsRepository.kt | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/CapturedItemsRepository.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/CapturedItemsRepository.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/CapturedItemsRepository.kt new file mode 100644 index 00000000..bc51fa0b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/CapturedItemsRepository.kt @@ -0,0 +1,132 @@ +package app.grapheneos.camera.ui.composable.screen.viewmodel + +import android.content.Context +import android.util.Log + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue + + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.CapturedItems +import app.grapheneos.camera.ITEM_TYPE_VIDEO +import app.grapheneos.camera.ktx.isDeviceLocked +import app.grapheneos.camera.ui.activities.InAppGallery +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "CapturedItemsRepository" + +class CapturedItemsRepository( + private val context: Context, + private val scope: CoroutineScope, + private val showVideosOnly: Boolean = false, + private val placeholderItem : CapturedItem? = null, + private val mediaItems: ArrayList? = null, + preload : Boolean = true, +) { + var isLoading by mutableStateOf(false) + + val capturedItems = mutableStateListOf() + + var secureModeState by mutableStateOf(null) + private set + + init { + if (preload) loadCapturedItems() + } + + fun loadCapturedItems( + onLoadComplete : () -> Unit = {} + ) { + // Only reload on config (i.e. secure mode states) changes + val secureModeState = context.isDeviceLocked() + if (this.secureModeState == secureModeState) { + onLoadComplete() + return + } + this.secureModeState = secureModeState + + scope.launch { + Log.i(TAG, "loadCapturedItems is loading items 1") + if (isLoading) return@launch + isLoading = true + + Log.i(TAG, "loadCapturedItems is loading items 2") + + capturedItems.clear() + + Log.i(TAG, "loadCapturedItems is loading items 3") + + if (placeholderItem != null) { + capturedItems.add(placeholderItem) + } + + Log.i(TAG, "loadCapturedItems is loading items 4") + + val items : List + + Log.i(TAG, "isSecureMode: $secureModeState") + + withContext(Dispatchers.IO) { + items = if (secureModeState) { + mediaItems ?: arrayListOf() + } else { + CapturedItems.get(context) + } + } + + Log.i(TAG, "items 1: $items") + + val relevantMediaItems = if (showVideosOnly) { + items.filter { capturedItem -> capturedItem.type == ITEM_TYPE_VIDEO } + } else { + items + } + + Log.i(TAG, "items 2: $items") + + val finalItems = relevantMediaItems.sortedByDescending { it.dateString } + + Log.i(TAG, "finalItems: $items") + + + + capturedItems.clear() + capturedItems.addAll(finalItems) + + isLoading = false + + onLoadComplete() + } + } + + suspend fun deleteItem(capturedItem: CapturedItem, context: Context) : Boolean { + val res = capturedItem.delete(context) + + if (res) { + // On main thread to ensure sequential deletion to avoid concurrent access + // exception when a lot of multiple items are being deleted together + withContext(Dispatchers.Main) { + capturedItems.remove(capturedItem) + } + } + + return res + } + + companion object { + fun get(context: Context) : CapturedItemsRepository { + assert(context is InAppGallery) { + "CapturedItemsRepository only support instantiating from InAppGallery activity currently" + } + + return (context as InAppGallery).capturedItemsRepository + } + } +} \ No newline at end of file From 25b6c886ef334b0645a5be3355e4681473a5c24d Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:42:55 +0530 Subject: [PATCH 15/31] Add Gallery screen composable, viewmodel and route --- .../composable/screen/routes/GalleryRoute.kt | 25 ++ .../ui/composable/screen/ui/GalleryScreen.kt | 269 ++++++++++++++++++ .../screen/viewmodel/GalleryViewModel.kt | 212 ++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt new file mode 100644 index 00000000..9306c2e5 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt @@ -0,0 +1,25 @@ +package app.grapheneos.camera.ui.composable.screen.routes + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemListNavType +import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemNavType +import kotlin.reflect.typeOf +import kotlinx.serialization.Serializable + +@Serializable +object GalleryRoute + +//@Serializable +//data class GalleryRoute( +// val isSecureMode : Boolean = false, +// val showVideosOnly : Boolean = false, +// val lastCapturedItem : CapturedItem? = null, +// val mediaItems : List? = null, +//) { +// companion object { +// val typeMap = mapOf( +// typeOf() to CapturedItemNavType, +// typeOf?>() to CapturedItemListNavType, +// ) +// } +//} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt new file mode 100644 index 00000000..1751f086 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt @@ -0,0 +1,269 @@ +package app.grapheneos.camera.ui.composable.screen.ui + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.EaseIn +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue + +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment + +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.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +import androidx.lifecycle.viewmodel.compose.viewModel +import app.grapheneos.camera.CapturedItem + +import app.grapheneos.camera.ui.composable.component.dialog.FileDeletionDialog +import app.grapheneos.camera.ui.composable.component.mediapreview.MediaPreview +import app.grapheneos.camera.ui.composable.component.topbar.gallery.GalleryTopBar +import app.grapheneos.camera.ui.composable.component.dialog.MediaInfoDialog +import app.grapheneos.camera.ui.composable.theme.AppColor + +import me.saket.telephoto.zoomable.rememberZoomableState +import me.saket.telephoto.zoomable.zoomable + +import app.grapheneos.camera.ITEM_TYPE_IMAGE +import app.grapheneos.camera.R +import app.grapheneos.camera.ui.composable.component.tooltip.QuickTooltip +import app.grapheneos.camera.ui.composable.component.tooltip.QuickTooltipVerticalDirection + +import app.grapheneos.camera.ui.composable.screen.viewmodel.GalleryViewModel +import kotlinx.coroutines.launch + +private const val TAG = "GalleryScreen" + +@Composable +fun GalleryScreen( + focusItem: CapturedItem? = null, + showVideoPlayerAction: (CapturedItem) -> Unit = {}, + showExtendedGalleryAction: () -> Unit = {}, + onExitAction: () -> Unit = {}, +) { + val context = LocalContext.current + + val zoomableState = rememberZoomableState() + + val coroutineScope = rememberCoroutineScope() + + val viewModel = viewModel { + GalleryViewModel(context) + } + + val backgroundColor by animateColorAsState( + label = "background_color_animation", + targetValue = if (viewModel.inFocusMode) Color.Black else AppColor.BackgroundColor, + animationSpec = tween(durationMillis = 300, easing = EaseIn), + ) + + // Ensure the focus is back to the focused item whenever capturedItems is updated + LaunchedEffect(viewModel.isLoadingCapturedItems, viewModel.capturedItems) { + if (viewModel.isLoadingCapturedItems) return@LaunchedEffect + + if (!viewModel.hasCapturedItems) { + Toast.makeText(context, R.string.empty_gallery, Toast.LENGTH_LONG).show() + onExitAction() + } + + val focusIndex = viewModel.capturedItems.indexOf(focusItem) + if (focusIndex != -1) { + viewModel.pagerState.scrollToPage(focusIndex) + } + } + + // Set the default/updated focus item (updated on load) + LaunchedEffect(focusItem) { + viewModel.focusItem = focusItem + } + + // Update the current focus item when the user slides between pages + LaunchedEffect(viewModel.pagerState.currentPage) { + val page = viewModel.pagerState.currentPage + if (page < viewModel.capturedItems.size) { + viewModel.focusItem = viewModel.capturedItems[page] + } + + } + + // Update zoom and focus state based on latest zoom state + LaunchedEffect(zoomableState.zoomFraction) { + zoomableState.zoomFraction?.let { zoomFraction -> + val isZoomedIn = zoomFraction >= 0.01f + viewModel.updateZoomedState(isZoomedIn) + } + } + + // Displays media info dialog when displayedMediaItem is not null + MediaInfoDialog( + mediaItemDetails = viewModel.displayedMediaItem, + dismissCallback = viewModel::hideMediaInfoDialog, + ) + + // Displays item deletion dialog when deletionItem is not null + FileDeletionDialog( + deletionItem = viewModel.deletionItem, + onDeleteAction = { item -> + viewModel.deleteMediaItem(context, item, onLastItemDeletion = onExitAction) + }, + dismissHandler = viewModel::hideDeletionPrompt, + ) + + Scaffold( + containerColor = backgroundColor, + + snackbarHost = { SnackbarHost(hostState = viewModel.snackbarHostState) }, + + floatingActionButton = { + AnimatedVisibility( + visible = !viewModel.inFocusMode, + enter = fadeIn(), + exit = fadeOut() + ) { + QuickTooltip( + message = stringResource(R.string.search_images), + defaultDirection = QuickTooltipVerticalDirection.TOP + ) { + FloatingActionButton( + onClick = { + coroutineScope.launch { + if (zoomableState.zoomFraction != 0f) { + zoomableState.resetZoom() + } + showExtendedGalleryAction() + } + }, + shape = CircleShape, + ) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(R.string.search_images), + ) + } + } + } + }, + + topBar = { + GalleryTopBar( + visible = !viewModel.inFocusMode, + onCloseAction = onExitAction, + + onEditAction = { chooseApp, modifyOriginal -> + viewModel.editMediaItem(context, chooseApp, modifyOriginal) + }, + + onDeleteAction = { + viewModel.promptItemDeletion() + }, + + onInfoAction = { + viewModel.displayMediaInfo(context) + }, + + onShareAction = { + viewModel.shareCurrentItem(context) + }, + ) + }, + + content = { innerPadding -> + + if (viewModel.isLoadingCapturedItems) { + Text( + text = stringResource(R.string.three_dots), + color = Color.White, + fontSize = 24.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(align = Alignment.CenterVertically), + ) + } else { + if (viewModel.hasCapturedItems) { + HorizontalPager( + state = viewModel.pagerState, + userScrollEnabled = !viewModel.isZoomedIn, + beyondViewportPageCount = 1, + modifier = Modifier + .padding( + innerPadding.calculateStartPadding(LayoutDirection.Ltr), + // the fixed padding of 56.dp has been added to GalleryImage to + // avoid having a fixed black bar on the top (56dp comes from + // material guidelines) + 0.dp, + innerPadding.calculateEndPadding(LayoutDirection.Ltr), + innerPadding.calculateBottomPadding(), + ) + .fillMaxSize() + ) { page -> + val capturedItem = viewModel.capturedItems[page] + + val modifier: Modifier = if (capturedItem.type == ITEM_TYPE_IMAGE) { + Modifier + .zoomable( + zoomableState, + onClick = { + viewModel.toggleFocusMode() + } + ) + } else { + Modifier + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + showVideoPlayerAction(capturedItem) + } + } + + MediaPreview( + capturedItem = capturedItem, + modifier = modifier + ) + } + } else { + Text( + text = stringResource(R.string.empty_gallery), + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(align = Alignment.CenterVertically), + ) + } + } + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt new file mode 100644 index 00000000..19f25b0b --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt @@ -0,0 +1,212 @@ +package app.grapheneos.camera.ui.composable.screen.viewmodel + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent + +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList + +import androidx.lifecycle.ViewModel + +import androidx.lifecycle.viewModelScope + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.R +import app.grapheneos.camera.ktx.isDeviceLocked +import app.grapheneos.camera.ktx.showOrReplaceSnackbar +import app.grapheneos.camera.ui.composable.model.MediaItemDetails +import kotlinx.coroutines.launch + +private const val TAG = "GalleryViewModel" + +class GalleryViewModel(context: Context) : ViewModel() { + + var focusItem by mutableStateOf(null) + + var isZoomedIn by mutableStateOf(false) + + var inFocusMode by mutableStateOf(false) + + var displayedMediaItem by mutableStateOf(null) + + var deletionItem by mutableStateOf(null) + + private val currentItem: CapturedItem + get() = capturedItems[pagerState.currentPage] + + private val capturedItemsViewModel = CapturedItemsRepository.get(context) + + val capturedItems : SnapshotStateList + get() = capturedItemsViewModel.capturedItems + + val hasCapturedItems: Boolean + get() = capturedItemsViewModel.capturedItems.isNotEmpty() + + val isLoadingCapturedItems by derivedStateOf { + capturedItemsViewModel.isLoading + } + + val snackbarHostState = SnackbarHostState() + + val pagerState = PagerState( + pageCount = { + capturedItems.size + } + ) + + fun displayMediaInfo(context: Context, item: CapturedItem = currentItem) { + viewModelScope.launch { + if (!hasCapturedItems) { + snackbarHostState.showOrReplaceSnackbar( + context.getString(R.string.unable_to_obtain_file_details) + ) + return@launch + } + + try { + displayedMediaItem = MediaItemDetails.forCapturedItem(context, item) + } catch (e: Exception) { + Log.i(TAG, "Unable to obtain file details for MediaInfoDialog") + e.printStackTrace() + snackbarHostState.showOrReplaceSnackbar( + context.getString(R.string.unable_to_obtain_file_details) + ) + } + + } + + } + + fun hideMediaInfoDialog() { + viewModelScope.launch { + displayedMediaItem = null + } + } + + fun promptItemDeletion(item: CapturedItem = currentItem) { + viewModelScope.launch { + deletionItem = item + } + } + + + fun deleteMediaItem( + context: Context, + item: CapturedItem, + onLastItemDeletion: () -> Unit, + ) { + viewModelScope.launch { + val result = capturedItemsViewModel.deleteItem(item, context) + + if (result) { + if (!hasCapturedItems) { + Toast.makeText(context, R.string.empty_gallery, Toast.LENGTH_LONG) + .show() + onLastItemDeletion() + } else { + snackbarHostState.showOrReplaceSnackbar( + context.getString(R.string.deleted_successfully) + ) + } + } else { + snackbarHostState.showOrReplaceSnackbar( + context.getString(R.string.deleting_unexpected_error) + ) + } + } + } + + fun hideDeletionPrompt() { + viewModelScope.launch { + deletionItem = null + } + } + + fun editMediaItem( + context: Context, + chooseApp: Boolean = false, + modifyOriginal: Boolean = false, + item: CapturedItem = currentItem, + ) { + viewModelScope.launch { + if (context.isDeviceLocked()) { + snackbarHostState.showOrReplaceSnackbar( + context.getString(R.string.edit_not_allowed) + ) + return@launch + } + + val editIntent = Intent(Intent.ACTION_EDIT).apply { + setDataAndType(item.uri, item.mimeType()) + putExtra(Intent.EXTRA_STREAM, item.uri) + if (modifyOriginal) { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } else { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + if (chooseApp) { + val chooser = Intent.createChooser( + editIntent, + context.getString(R.string.edit_image) + ).apply { + putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false) + } + context.startActivity(chooser) + } else { + try { + context.startActivity(editIntent) + } catch (ignored: ActivityNotFoundException) { + snackbarHostState.showOrReplaceSnackbar( + context.getString(R.string.no_editor_app_error) + ) + } + } + } + } + + fun shareCurrentItem( + context: Context, + item: CapturedItem = currentItem, + ) { + viewModelScope.launch { + if (context.isDeviceLocked()) { + snackbarHostState.showOrReplaceSnackbar( + context.getString(R.string.sharing_not_allowed) + ) + return@launch + } + + val share = Intent(Intent.ACTION_SEND) + share.putExtra(Intent.EXTRA_STREAM, item.uri) + share.setDataAndType(item.uri, item.mimeType()) + share.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + + context.startActivity( + Intent.createChooser(share, context.getString(R.string.share_image)) + ) + } + } + + fun updateZoomedState(zoomedIn: Boolean) { + viewModelScope.launch { + isZoomedIn = zoomedIn + inFocusMode = zoomedIn + } + } + + fun toggleFocusMode() { + viewModelScope.launch { + inFocusMode = !inFocusMode + } + } +} \ No newline at end of file From 2e313af086fe47b82d24380ca38f8b3beeec9869 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:43:08 +0530 Subject: [PATCH 16/31] Add Extended Gallery screen composable, viewmodel and route --- .../screen/routes/ExtendedGalleryRoute.kt | 24 ++ .../screen/ui/ExtendedGalleryScreen.kt | 232 ++++++++++++++++++ .../viewmodel/ExtendedGalleryViewModel.kt | 232 ++++++++++++++++++ 3 files changed, 488 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt new file mode 100644 index 00000000..816e23a1 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt @@ -0,0 +1,24 @@ +package app.grapheneos.camera.ui.composable.screen.routes + +import kotlinx.serialization.Serializable + +@Serializable +object ExtendedGalleryRoute + +//import app.grapheneos.camera.CapturedItem +//import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemListNavType +//import kotlin.reflect.typeOf +//import kotlinx.serialization.Serializable +// +//@Serializable +//data class ExtendedGalleryRoute( +// val isSecureMode : Boolean = false, +// val showVideosOnly : Boolean = false, +// val mediaItems : List? = null, +//) { +// companion object { +// val typeMap = mapOf( +// typeOf?>() to CapturedItemListNavType, +// ) +// } +//} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt new file mode 100644 index 00000000..75ba15ec --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt @@ -0,0 +1,232 @@ +package app.grapheneos.camera.ui.composable.screen.ui + +import androidx.activity.compose.BackHandler +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.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.grid.GridCells + +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Circle + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.R +import app.grapheneos.camera.ktx.header +import app.grapheneos.camera.ktx.requestDeviceUnlock +import app.grapheneos.camera.ui.composable.component.dialog.MultipleFileDeletionDialog +import app.grapheneos.camera.ui.composable.component.mediapreview.SQUARE_MEDIA_PREVIEW_SIZE +import app.grapheneos.camera.ui.composable.component.mediapreview.SquareMediaPreview +import app.grapheneos.camera.ui.composable.component.topbar.extendedgallery.ExtendedGalleryTopBar + +import app.grapheneos.camera.ui.composable.screen.viewmodel.ExtendedGalleryViewModel +import app.grapheneos.camera.util.getHumanReadableDate + +@Composable +fun ExtendedGalleryScreen( + showMediaItemAction: (CapturedItem) -> Unit = {}, + onExitAction: () -> Unit = {} +) { + val context = LocalContext.current + + val viewModel = viewModel { + ExtendedGalleryViewModel(context) + } + + BackHandler { + if (viewModel.selectMode) { + viewModel.exitSelectionMode() + } else { + onExitAction() + } + } + + if (viewModel.isDeletionDialogVisible) { + MultipleFileDeletionDialog( + deletionItemsCount = viewModel.selectedItems.size, + onDeleteConfirmationAction = { + viewModel.deleteSelectedItems(context, onLastItemDeletion = onExitAction) + }, + dismissCallback = viewModel::dismissDeletionDialog + ) + } + + + Scaffold ( + snackbarHost = { + SnackbarHost(viewModel.snackBarHostState) + }, + + topBar = { + ExtendedGalleryTopBar( + selectModeEnabled = viewModel.selectMode, + secureMode = viewModel.isSecureCapturedItemsLoaded, + selectedItems = viewModel.selectedItems, + + onEnableSelectMode = viewModel::enterSelectionMode, + onDisableSelectMode = viewModel::exitSelectionMode, + onAllItemsSelectAction = viewModel::selectAllItems, + onDeleteItemsAction = { + viewModel.showDeletionDialog(context) + }, + + onSharedItemsAction = { + viewModel.shareSelectedItems(context) + }, + + onDeviceUnlockRequest = context::requestDeviceUnlock, + + onExitAction = onExitAction, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (viewModel.isLoadingCapturedItems) { + Text( + stringResource(R.string.loading_generic), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxSize() + ) + } else { + if (viewModel.hasCapturedItems) { + LazyVerticalGrid( + columns = GridCells.Adaptive(SQUARE_MEDIA_PREVIEW_SIZE), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + viewModel.groupedCapturedItems.forEach { group -> + + val date = getHumanReadableDate(group.key) + val capturedItemsOnDate = group.value + + header { + Row( + modifier = Modifier + .padding( + start = 14.dp, + end = 14.dp, + top = 16.dp, + bottom = 8.dp, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + date, + style = MaterialTheme.typography.titleLarge, + ) + + IconButton( + onClick = { + viewModel.toggleGroupSelection(capturedItemsOnDate) + }, + ) { + if (viewModel.selectMode) { + if (viewModel.hasSelectedAll(capturedItemsOnDate)) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = null + ) + } else { + Icon( + Icons.Outlined.Circle, + contentDescription = null + ) + } + } else { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = null + ) + } + + } + } + } + + items(capturedItemsOnDate) { + capturedItem -> + Box { + SquareMediaPreview( + capturedItem = capturedItem, + onClick = { + if (viewModel.selectMode) { + viewModel.toggleSelection(capturedItem) + } else { + showMediaItemAction(capturedItem) + } + }, + onLongClick = { + viewModel.toggleSelection(capturedItem) + } + ) + + if (viewModel.hasSelected(capturedItem)) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .aspectRatio(1f) + .fillMaxSize() + .background(Color.Black.copy(.7f)), + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + ) + } + } + } + } + } + + } + } else { + Text( + stringResource(R.string.empty_gallery), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(align = Alignment.CenterVertically), + + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt new file mode 100644 index 00000000..4b24f331 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt @@ -0,0 +1,232 @@ +package app.grapheneos.camera.ui.composable.screen.viewmodel + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.ViewModel +import androidx.compose.material3.SnackbarHostState +import androidx.lifecycle.viewModelScope + +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.R +import app.grapheneos.camera.ktx.isDeviceLocked +import app.grapheneos.camera.ktx.showOrReplaceSnackbar +import app.grapheneos.camera.util.getMimeTypeForItems +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch + +private const val TAG = "ExtendedGalleryViewModel" + +class ExtendedGalleryViewModel(context: Context) : ViewModel() { + + val selectedItems = mutableStateListOf() + + var selectMode by mutableStateOf(false) + private set + + private val capturedItemsViewModel = CapturedItemsRepository.get(context) + + private val capturedItems : SnapshotStateList + get() = capturedItemsViewModel.capturedItems + + val groupedCapturedItems by derivedStateOf { + capturedItems.groupBy { + it.dateString.substringBefore('_') + }.toSortedMap { a, b -> + b.toInt() - a.toInt() + } + } + + val hasCapturedItems by derivedStateOf { + capturedItems.isNotEmpty() + } + + val isLoadingCapturedItems by derivedStateOf { + capturedItemsViewModel.isLoading + } + + val isSecureCapturedItemsLoaded by derivedStateOf { + capturedItemsViewModel.secureModeState == true + } + + var isDeletionDialogVisible by mutableStateOf(false) + + val snackBarHostState = SnackbarHostState() + + fun showDeletionDialog(context: Context) { + viewModelScope.launch { + if (selectedItems.isEmpty()) { + snackBarHostState.showOrReplaceSnackbar( + context.getString(R.string.select_an_item_request) + ) + return@launch + } + isDeletionDialogVisible = true + } + } + + fun dismissDeletionDialog() { + viewModelScope.launch { + isDeletionDialogVisible = false + } + } + + fun toggleSelection(capturedItem: CapturedItem, exitModeOnNoItemSelected : Boolean = true) { + viewModelScope.launch { + enterSelectionMode() + if (hasSelected(capturedItem)) { + deselectItem(capturedItem, exitModeOnNoItemSelected) + } else { + selectItem(capturedItem, true) + } + } + } + + fun toggleGroupSelection(capturedItems: Collection, exitModeOnNoItemSelected : Boolean = true) { + viewModelScope.launch { + if (hasSelectedAll(capturedItems)) { + deselectItems(capturedItems, exitModeOnNoItemSelected) + } else { + selectItems(capturedItems) + } + } + } + + fun selectAllItems() { + viewModelScope.launch { + selectedItems.clear() + selectedItems.addAll(capturedItems) + } + } + + // Ensures that the app is in selection mode + fun enterSelectionMode() { + viewModelScope.launch { + selectMode = true + } + } + + // Ensures that the app is no longer in selection mode + fun exitSelectionMode() { + viewModelScope.launch { + selectedItems.clear() + selectMode = false + } + } + + fun hasSelected(capturedItem: CapturedItem): Boolean { + return capturedItem in selectedItems + } + + fun hasSelectedAll(capturedItems: Collection) : Boolean { + return selectedItems.containsAll(capturedItems) + } + + private fun selectItem(capturedItem: CapturedItem, skipCheck : Boolean = false) { + viewModelScope.launch { + enterSelectionMode() + if (skipCheck || !hasSelected(capturedItem)) { + selectedItems.add(capturedItem) + } + } + } + + private fun deselectItem(capturedItem: CapturedItem, exitModeOnNoItemSelected: Boolean = true) { + viewModelScope.launch { + selectedItems.remove(capturedItem) + + if (exitModeOnNoItemSelected) { + if (selectedItems.isEmpty()) { + exitSelectionMode() + } + } + } + } + + private fun selectItems(capturedItems: Collection) { + viewModelScope.launch { + for (capturedItem in capturedItems) selectItem(capturedItem) + } + } + + private fun deselectItems(capturedItems: Collection, exitModeOnNoItemSelected : Boolean = true) { + viewModelScope.launch { + for (capturedItem in capturedItems) deselectItem(capturedItem, exitModeOnNoItemSelected) + } + } + + fun shareSelectedItems(context: Context) { + viewModelScope.launch { + if (context.isDeviceLocked()) { + snackBarHostState.showOrReplaceSnackbar( + context.getString(R.string.sharing_not_allowed) + ) + return@launch + } + + if (selectedItems.isEmpty()) { + snackBarHostState.showOrReplaceSnackbar( + context.getString(R.string.select_an_item_request) + ) + return@launch + } + + val uris = selectedItems.mapTo(arrayListOf()) { it.uri } + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + action = Intent.ACTION_SEND_MULTIPLE + putParcelableArrayListExtra( + Intent.EXTRA_STREAM, + uris + ) + type = getMimeTypeForItems(selectedItems) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + + context.startActivity( + Intent.createChooser( + shareIntent, + context.getString(R.string.share_image) + ) + ) + } + } + + fun deleteSelectedItems( + context: Context, + onLastItemDeletion: () -> Unit = {}, + ) { + viewModelScope.launch { + + val selectedItemsSize = selectedItems.size + + val failedDeletions = selectedItems.map { capturedItem -> async(Dispatchers.IO) { + capturedItemsViewModel.deleteItem(capturedItem, context) + } }.awaitAll().count { res -> !res } + + exitSelectionMode() + + if (!hasCapturedItems) { + onLastItemDeletion() + } + + if (failedDeletions != 0) { + snackBarHostState.showOrReplaceSnackbar( + context.getString( + R.string.failed_multiple_deletion_message, + failedDeletions, + selectedItemsSize + ) + ) + } + } + } +} + From 31ae47dc4c6af82dd7a32b8248bd779611b4b9a2 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:43:56 +0530 Subject: [PATCH 17/31] Add CameraApp composable (represents the entry point of the composable app) --- .../camera/ui/composable/CameraApp.kt | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt new file mode 100644 index 00000000..a6421d09 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt @@ -0,0 +1,103 @@ +package app.grapheneos.camera.ui.composable + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import app.grapheneos.camera.CapturedItem +import app.grapheneos.camera.ITEM_TYPE_VIDEO +import app.grapheneos.camera.ktx.popBackStack +import app.grapheneos.camera.ui.composable.screen.ui.ExtendedGalleryScreen +import app.grapheneos.camera.ui.composable.model.VideoUri +import app.grapheneos.camera.ui.composable.screen.routes.ExtendedGalleryRoute + +import app.grapheneos.camera.ui.composable.screen.routes.GalleryRoute +import app.grapheneos.camera.ui.composable.screen.routes.VideoPlayerRoute + +import app.grapheneos.camera.ui.composable.screen.ui.GalleryScreen +import app.grapheneos.camera.ui.composable.screen.ui.VideoPlayerScreen +import app.grapheneos.camera.ui.composable.theme.appColorScheme +import app.grapheneos.camera.ui.composable.theme.appTypography + +@Composable +fun CameraApp( + initialRoute: Any, + onExitAction : () -> Unit, +) { + val navController = rememberNavController() + + val defaultBackAction = { + // Go to previous screen if present, else perform exit action + navController.popBackStack(onBackStackEmpty = onExitAction) + } + + MaterialTheme( + colorScheme = appColorScheme(), + typography = appTypography(), + ) { + NavHost( + navController = navController, + startDestination = initialRoute, + enterTransition = { + EnterTransition.None + }, + exitTransition = { + ExitTransition.None + } + ) { + composable { backStackEntry -> + + val focusItem = backStackEntry.savedStateHandle + .get("FOCUS_ITEM") + + GalleryScreen( + focusItem = focusItem, + showVideoPlayerAction = { capturedItem -> + navController.navigate( + VideoPlayerRoute(videoUri = VideoUri(capturedItem.uri)) + ) + }, + showExtendedGalleryAction = { + navController.navigate(ExtendedGalleryRoute) + }, + onExitAction = defaultBackAction, + ) + } + + composable { + ExtendedGalleryScreen( + showMediaItemAction = { capturedItem -> + if (capturedItem.type == ITEM_TYPE_VIDEO) { + navController.navigate( + VideoPlayerRoute( + videoUri = VideoUri(capturedItem.uri) + ) + ) + } else { + navController.previousBackStackEntry?.savedStateHandle + ?.set("FOCUS_ITEM", capturedItem) + navController.popBackStack(GalleryRoute, false) + } + + }, + onExitAction = defaultBackAction + ) + } + + composable(typeMap = VideoPlayerRoute.typeMap) { + val args = it.toRoute() + + VideoPlayerScreen( + mediaUri = args.videoUri.uri, + onExitAction = defaultBackAction, + ) + } + } + } + +} \ No newline at end of file From 029ee99abfc503e0f277d79ce8547b1622eccff8 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:44:53 +0530 Subject: [PATCH 18/31] Use CameraApp composable (and setup CapturedItemsRepository for use) --- .../camera/ui/activities/InAppGallery.kt | 659 ++---------------- 1 file changed, 55 insertions(+), 604 deletions(-) diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt index 01a70bf9..50142db8 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt @@ -1,443 +1,53 @@ package app.grapheneos.camera.ui.activities -import android.animation.ArgbEvaluator -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.media.MediaMetadataRetriever -import android.net.Uri +import android.content.IntentFilter import android.os.Bundle -import android.os.Handler -import android.os.VibrationEffect -import android.os.Vibrator -import android.provider.DocumentsContract -import android.provider.MediaStore -import android.provider.MediaStore.MediaColumns -import android.provider.OpenableColumns -import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.RelativeLayout -import android.widget.Toast -import androidx.activity.enableEdgeToEdge + +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.os.BundleCompat -import androidx.core.view.ViewCompat +import androidx.lifecycle.lifecycleScope +import androidx.activity.enableEdgeToEdge +import app.grapheneos.camera.CapturedItem import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import androidx.viewpager2.widget.ViewPager2 -import androidxc.exifinterface.media.ExifInterface import app.grapheneos.camera.AutoFinishOnSleep -import app.grapheneos.camera.CapturedItem -import app.grapheneos.camera.CapturedItems -import app.grapheneos.camera.GSlideTransformer -import app.grapheneos.camera.GallerySliderAdapter -import app.grapheneos.camera.ITEM_TYPE_VIDEO -import app.grapheneos.camera.R -import app.grapheneos.camera.databinding.GalleryBinding +import androidx.core.view.WindowInsetsCompat + +import app.grapheneos.camera.ui.composable.CameraApp +import app.grapheneos.camera.ui.composable.screen.routes.GalleryRoute +import app.grapheneos.camera.ui.composable.screen.viewmodel.CapturedItemsRepository + import app.grapheneos.camera.util.getParcelableArrayListExtra import app.grapheneos.camera.util.getParcelableExtra -import app.grapheneos.camera.util.storageLocationToUiString -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.concurrent.Executors -import kotlin.properties.Delegates class InAppGallery : AppCompatActivity() { - lateinit var binding: GalleryBinding - lateinit var gallerySlider: ViewPager2 - var gallerySliderAdapter: GallerySliderAdapter? = null - - val asyncLoaderOfCapturedItems = Executors.newSingleThreadExecutor() - val asyncImageLoader = Executors.newSingleThreadExecutor() - - private lateinit var snackBar: Snackbar - private var ogColor by Delegates.notNull() - var isSecureMode = false private set - private lateinit var rootView: View - - private val autoFinisher = AutoFinishOnSleep(this) - - private var lastViewedMediaItem : CapturedItem? = null - - private lateinit var windowInsetsController: WindowInsetsControllerCompat - companion object { const val INTENT_KEY_SECURE_MODE = "is_secure_mode" const val INTENT_KEY_VIDEO_ONLY_MODE = "video_only_mode" const val INTENT_KEY_LIST_OF_SECURE_MODE_CAPTURED_ITEMS = "secure_mode_items" const val INTENT_KEY_LAST_CAPTURED_ITEM = "last_captured_item" - - const val LAST_VIEWED_ITEM_KEY = "LAST_VIEWED_ITEM_KEY" - - @SuppressLint("SimpleDateFormat") - fun convertTime(time: Long, showTimeZone: Boolean = true): String { - val date = Date(time) - val format = SimpleDateFormat( - if (showTimeZone) { - "yyyy-MM-dd HH:mm:ss z" - } else { - "yyyy-MM-dd HH:mm:ss" - } - ) - format.timeZone = TimeZone.getDefault() - return format.format(date) - } - - fun convertTimeForVideo(time: String): String { - val dateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss.SSS'Z'", Locale.US) - dateFormat.timeZone = TimeZone.getTimeZone("UTC") - val parsedDate = dateFormat.parse(time) - return convertTime(parsedDate?.time ?: 0) - } - - fun convertTimeForPhoto(time: String, offset: String? = null): String { - - val timestamp = if (offset != null) { - "$time $offset" - } else { - time - } - - val dateFormat = SimpleDateFormat( - if (offset == null) { - "yyyy:MM:dd HH:mm:ss" - } else { - "yyyy:MM:dd HH:mm:ss Z" - }, Locale.US - ) - - if (offset == null) { - dateFormat.timeZone = TimeZone.getDefault() - } - val parsedDate = dateFormat.parse(timestamp) - return convertTime(parsedDate?.time ?: 0, offset != null) - } - - fun getRelativePath(ctx: Context, uri: Uri, path: String?, fileName: String): String { - if (path == null) { - return storageLocationToUiString(ctx, uri.toString()) - } - - return "${ctx.getString(R.string.main_storage)}/$path$fileName" - } - } - - private fun getCurrentItem(): CapturedItem { - return gallerySliderAdapter!!.getCurrentItem() - } - - override fun onSupportNavigateUp(): Boolean { - finish() - return true - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.gallery, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - return when (item.itemId) { - R.id.edit_icon -> { - editCurrentMedia() - true - } - R.id.edit_with -> { - editCurrentMedia(withDefault = false) - true - } - R.id.delete_icon -> { - deleteCurrentMedia() - true - } - - R.id.info -> { - showCurrentMediaDetails() - true - } - - R.id.share_icon -> { - shareCurrentMedia() - true - } - - else -> super.onOptionsItemSelected(item) - } } - private fun editCurrentMedia(withDefault: Boolean = true) { - if (isSecureMode) { - showMessage(getString(R.string.edit_not_allowed)) - return - } - - val curItem = getCurrentItem() - - val editIntent = Intent(Intent.ACTION_EDIT).apply { - setDataAndType(curItem.uri, curItem.mimeType()) - putExtra(Intent.EXTRA_STREAM, curItem.uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - if (withDefault) { - try { - startActivity(editIntent) - } catch (ignored: ActivityNotFoundException) { - showMessage(getString(R.string.no_editor_app_error)) - } - } else { - val chooser = Intent.createChooser(editIntent, getString(R.string.edit_image)).apply { - putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false) - } - startActivity(chooser) - } - } - - private fun deleteCurrentMedia() { - val curItem = getCurrentItem() - - MaterialAlertDialogBuilder(this) - .setTitle(R.string.delete_title) - .setMessage(getString(R.string.delete_description, curItem.uiName())) - .setPositiveButton(R.string.delete) { _, _ -> - var res = false - - val uri = curItem.uri - try { - if (uri.authority == MediaStore.AUTHORITY) { - res = contentResolver.delete(uri, null, null) > 0 - } else { - res = DocumentsContract.deleteDocument(contentResolver, uri) - } - } catch (e: Exception) { - e.printStackTrace() - } - - if (res) { - showMessage(getString(R.string.deleted_successfully)) - gallerySliderAdapter!!.removeItem(curItem) - } else { - showMessage(getString(R.string.deleting_unexpected_error)) - } - } - .setNegativeButton(R.string.cancel, null) - .show() - - } - - private fun showCurrentMediaDetails() { - val curItem = getCurrentItem() - - var relativePath: String? = null - var fileName: String? = null - var size: Long = 0 - - var dateAdded: String? = null - var dateModified: String? = null - - try { - // note that the first column (RELATIVE_PATH) is undefined for SAF Uris - val projection = arrayOf(MediaColumns.RELATIVE_PATH, OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) - - contentResolver.query(curItem.uri, projection, null,null)?.use { - if (it.moveToFirst()) { - relativePath = it.getString(0) - fileName = it.getString(1) - size = it.getLong(2) - } - } - - if (fileName == null) { - showMessage(getString(R.string.unable_to_obtain_file_details)) - return - } - - if (curItem.type == ITEM_TYPE_VIDEO) { - MediaMetadataRetriever().use { - it.setDataSource(this, curItem.uri) - dateAdded = convertTimeForVideo(it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)!!) - dateModified = dateAdded - } - } else { - contentResolver.openInputStream(curItem.uri)?.use { stream -> - val eInterface = ExifInterface(stream) - - val offset = eInterface.getAttribute(ExifInterface.TAG_OFFSET_TIME) - - if (eInterface.hasAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)) { - dateAdded = convertTimeForPhoto( - eInterface.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)!!, - offset - ) - } - - if (eInterface.hasAttribute(ExifInterface.TAG_DATETIME)) { - dateModified = convertTimeForPhoto( - eInterface.getAttribute(ExifInterface.TAG_DATETIME)!!, - offset - ) - } - } - } - } catch (e: Exception) { - Log.d("showCurrentMediaDetails", "unable to obtain file details", e) - showMessage(getString(R.string.unable_to_obtain_file_details)) - return - } - - - val alertDialog = MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.file_details)) - - val detailsBuilder = StringBuilder() - - detailsBuilder.append("\n", getString(R.string.file_name_generic), "\n") - detailsBuilder.append(fileName) - detailsBuilder.append("\n\n") - - detailsBuilder.append(getString(R.string.file_path), "\n") - detailsBuilder.append(getRelativePath(this, curItem.uri, relativePath, fileName!!)) - detailsBuilder.append("\n\n") - - detailsBuilder.append(getString(R.string.file_size), "\n") - if (size == 0L) { - detailsBuilder.append(getString(R.string.loading_generic)) - } else { - detailsBuilder.append( - String.format( - "%.2f", - (size / (1000f * 1000f)) - ) - ) - detailsBuilder.append(" MB") - } - - detailsBuilder.append("\n\n") - - detailsBuilder.append(getString(R.string.file_created_on), "\n") - if (dateAdded == null) { - detailsBuilder.append(getString(R.string.not_found_generic)) - } else { - detailsBuilder.append(dateAdded) - } - - detailsBuilder.append("\n\n") - - detailsBuilder.append(getString(R.string.last_modified_on), "\n") - if (dateModified == null) { - detailsBuilder.append(getString(R.string.not_found_generic)) - } else { - detailsBuilder.append(dateModified) - } - - alertDialog.setMessage(detailsBuilder) - - alertDialog.setPositiveButton(getString(R.string.ok), null) - - - alertDialog.show() - } - - private fun animateBackgroundToBlack() { - - val cBgColor = (rootView.background as ColorDrawable).color - - if (cBgColor == Color.BLACK) { - return - } - - val bgColorAnim = ValueAnimator.ofObject( - ArgbEvaluator(), - ogColor, - Color.BLACK - ) - bgColorAnim.duration = 300 - bgColorAnim.addUpdateListener { animator -> - val color = animator.animatedValue as Int - rootView.setBackgroundColor(color) - } - bgColorAnim.start() - } - - private fun animateBackgroundToOriginal() { - - val cBgColor = (rootView.background as ColorDrawable).color - - if (cBgColor == ogColor) { - return - } - - val bgColorAnim = ValueAnimator.ofObject( - ArgbEvaluator(), - Color.BLACK, - ogColor, - ) - bgColorAnim.duration = 300 - bgColorAnim.addUpdateListener { animator -> - val color = animator.animatedValue as Int - this.rootView.setBackgroundColor(color) - } - bgColorAnim.start() - } - - private fun animateShadeToTransparent() { - if (binding.shade.alpha == 0f) { - return - } - - binding.shade.animate().apply { - duration = 300 - alpha(0f) - } - } + private val autoFinisher = AutoFinishOnSleep(this) + + lateinit var capturedItemsRepository : CapturedItemsRepository - private fun animateShadeToOriginal() { - if (binding.shade.alpha == 1f) { - return - } + private lateinit var windowInsetsController: WindowInsetsControllerCompat - binding.shade.animate().apply { - duration = 300 - alpha(1f) + private val deviceUnlockStateListener = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + capturedItemsRepository.loadCapturedItems() } } - private fun shareCurrentMedia() { - if (isSecureMode) { - showMessage(getString(R.string.sharing_not_allowed)) - return - } - - val curItem = getCurrentItem() - - val share = Intent(Intent.ACTION_SEND) - share.putExtra(Intent.EXTRA_STREAM, curItem.uri) - share.setDataAndType(curItem.uri, curItem.mimeType()) - share.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - - startActivity(Intent.createChooser(share, getString(R.string.share_image))) - } - override fun onCreate(savedInstanceState: Bundle?) { - isSecureMode = intent.getBooleanExtra(INTENT_KEY_SECURE_MODE, false) - super.onCreate(savedInstanceState) enableEdgeToEdge() windowInsetsController = WindowCompat.getInsetsController(window, window.decorView).apply { @@ -446,221 +56,62 @@ class InAppGallery : AppCompatActivity() { show(WindowInsetsCompat.Type.systemBars()) } + isSecureMode = intent.getBooleanExtra(INTENT_KEY_SECURE_MODE, false) + val showVideosOnly = intent.getBooleanExtra(INTENT_KEY_VIDEO_ONLY_MODE, false) + if (isSecureMode) { setShowWhenLocked(true) setTurnScreenOn(true) autoFinisher.start() } - ogColor = ContextCompat.getColor(this, R.color.system_neutral1_900) - binding = GalleryBinding.inflate(layoutInflater) - setContentView(binding.root) - - supportActionBar?.let { - it.setDisplayShowTitleEnabled(false) - it.setDisplayHomeAsUpEnabled(true) - } - - rootView = binding.rootView - rootView.setOnClickListener { - if (gallerySliderAdapter != null) { - toggleUIState() - } - } - - gallerySlider = binding.gallerySlider - snackBar = Snackbar.make(binding.snackbarAnchor, "", Snackbar.LENGTH_LONG) - gallerySlider.setPageTransformer(GSlideTransformer()) - ViewCompat.setOnApplyWindowInsetsListener(binding.snackbarAnchor) { view, insets -> - val systemBars = - insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars()) - view.y = -systemBars.bottom.toFloat() - snackBar.setAnchorView(view) - insets - } - - if (savedInstanceState != null) { - lastViewedMediaItem = BundleCompat.getParcelable(savedInstanceState, LAST_VIEWED_ITEM_KEY, CapturedItem::class.java) - } - - val intent = this.intent - - val showVideosOnly = intent.getBooleanExtra(INTENT_KEY_VIDEO_ONLY_MODE, false) - val listOfSecureModeCapturedItems = getParcelableArrayListExtra( - intent, INTENT_KEY_LIST_OF_SECURE_MODE_CAPTURED_ITEMS) - - asyncLoaderOfCapturedItems.execute { - val unprocessedItems: List = try { - CapturedItems.get(this) - } catch (e: InterruptedException) { - // activity was destroyed and exectutor.shutdownNow() was called, which interrupts - // executor threads - return@execute - } - val setOfSecureModeCapturedItems = listOfSecureModeCapturedItems?.toHashSet() - val items = ArrayList(unprocessedItems.size) - - unprocessedItems.forEach { item -> - if (showVideosOnly) { - if (item.type != ITEM_TYPE_VIDEO) { - return@forEach - } - } - - setOfSecureModeCapturedItems?.let { - if (!it.contains(item)) { - return@forEach - } - } - - items.add(item) - } - items.sortByDescending { it.dateString } - - mainExecutor.execute { asyncResultReady(items) } - } - - if (lastViewedMediaItem == null) { - val lastCapturedItem = getParcelableExtra(intent, INTENT_KEY_LAST_CAPTURED_ITEM) + val mediaItems = getParcelableArrayListExtra( + intent, + INTENT_KEY_LIST_OF_SECURE_MODE_CAPTURED_ITEMS + ) - if (lastCapturedItem != null) { - val list = ArrayList() - list.add(lastCapturedItem) - GallerySliderAdapter(this, list).let { - gallerySliderAdapter = it - gallerySlider.adapter = it - } - } else { - Handler(mainLooper).postDelayed({ - if (gallerySliderAdapter == null) { - binding.placeholderText.root.visibility = View.VISIBLE - } - }, 500) - } - } + val lastCapturedItem = getParcelableExtra( + intent, + INTENT_KEY_LAST_CAPTURED_ITEM + ) - supportActionBar?.setBackgroundDrawable(null) + // Initial and start loading captured items in background + capturedItemsRepository = CapturedItemsRepository( + context = this, + scope = lifecycleScope, + mediaItems = mediaItems, + showVideosOnly = showVideosOnly, + placeholderItem = lastCapturedItem, + ) - ViewCompat.setOnApplyWindowInsetsListener(binding.shade) { view, insets -> - val systemBars = insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars()) - val actionBarHeight = resources.getDimensionPixelSize(R.dimen.action_bar_height) - view.layoutParams = - RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.MATCH_PARENT, - systemBars.top + actionBarHeight - ) - view.background = ContextCompat.getDrawable(this@InAppGallery, R.drawable.shade) - insets + setContent { + CameraApp( + initialRoute = GalleryRoute, + onExitAction = this::finish, + ) } } - override fun onResume() { - super.onResume() - showUI() - } - - fun asyncResultReady(items: ArrayList) { - if (isDestroyed) { - return - } - - if (items.isEmpty()) { - Toast.makeText(applicationContext, R.string.empty_gallery, Toast.LENGTH_SHORT).show() - finish() - return - } + override fun onStart() { + super.onStart() - var capturedItemPosition = 0 - - if (lastViewedMediaItem != null) { - for (i in 0.. - // this check is needed to avoid showing preloaded item twice (it's not guaranteed - // that it'll be first in the list) - if (index > 50 || item != preloadedItem) { - adapterItems.add(item) - } - } - existingAdapter.notifyItemRangeInserted(1, items.size - 1) - } - gallerySlider.setCurrentItem(capturedItemPosition, false) - showUI() - } - - fun toggleUIState() { - supportActionBar?.let { - if (it.isShowing) { - hideUI() - } else { - showUI() - } - } - } - - fun showUI() { - supportActionBar?.let { - it.show() - animateBackgroundToOriginal() - } - animateShadeToOriginal() - windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) - } - - fun hideUI() { - supportActionBar?.let { - it.hide() - animateBackgroundToBlack() - } - animateShadeToTransparent() - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + ) } override fun onDestroy() { super.onDestroy() - asyncLoaderOfCapturedItems.shutdownNow() - asyncImageLoader.shutdownNow() if (isSecureMode) { autoFinisher.stop() } } - fun vibrateDevice() { - val vibrator = getSystemService(Vibrator::class.java) - vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) - } - - fun showMessage(msg: String) { - snackBar.setText(msg) - snackBar.show() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - - gallerySliderAdapter?.let { - outState.putParcelable(LAST_VIEWED_ITEM_KEY, it.items[gallerySlider.currentItem]) - } + override fun onStop() { + super.onStop() + unregisterReceiver(deviceUnlockStateListener) } -} +} \ No newline at end of file From a4eeb1dfcbbdd1f75edcaade4408aef8564312fa Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:45:28 +0530 Subject: [PATCH 19/31] Remove system actionbar from in-app gallery --- app/src/main/AndroidManifest.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 36985190..f40f6704 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -169,6 +169,7 @@ @@ -176,6 +177,7 @@ From ee459cdf7792fae50d754ef7d69ef240f16a6daa Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 14 Oct 2024 01:46:48 +0530 Subject: [PATCH 20/31] Remove old unused code (related to views framework) --- .../grapheneos/camera/GSlideTransformer.kt | 47 --- .../grapheneos/camera/GallerySliderAdapter.kt | 153 ------- .../grapheneos/camera/ui/ZoomableImageView.kt | 383 ------------------ .../camera/ui/fragment/GallerySlide.kt | 8 - app/src/main/res/layout/gallery.xml | 31 -- .../main/res/layout/gallery_placeholder.xml | 10 - app/src/main/res/layout/gallery_slide.xml | 27 -- app/src/main/res/layout/video_player.xml | 20 - app/src/main/res/menu/gallery.xml | 37 -- 9 files changed, 716 deletions(-) delete mode 100644 app/src/main/java/app/grapheneos/camera/GSlideTransformer.kt delete mode 100644 app/src/main/java/app/grapheneos/camera/GallerySliderAdapter.kt delete mode 100644 app/src/main/java/app/grapheneos/camera/ui/ZoomableImageView.kt delete mode 100644 app/src/main/java/app/grapheneos/camera/ui/fragment/GallerySlide.kt delete mode 100644 app/src/main/res/layout/gallery.xml delete mode 100644 app/src/main/res/layout/gallery_placeholder.xml delete mode 100644 app/src/main/res/layout/gallery_slide.xml delete mode 100644 app/src/main/res/layout/video_player.xml delete mode 100644 app/src/main/res/menu/gallery.xml diff --git a/app/src/main/java/app/grapheneos/camera/GSlideTransformer.kt b/app/src/main/java/app/grapheneos/camera/GSlideTransformer.kt deleted file mode 100644 index 696593fa..00000000 --- a/app/src/main/java/app/grapheneos/camera/GSlideTransformer.kt +++ /dev/null @@ -1,47 +0,0 @@ -package app.grapheneos.camera - -import android.view.View -import androidx.viewpager2.widget.ViewPager2 -import kotlin.math.abs - -class GSlideTransformer : ViewPager2.PageTransformer { - - companion object { - private const val MIN_SCALE = 0.75f - } - - override fun transformPage(view: View, position: Float) { - view.apply { - val pageWidth = width - when { - position < -1 -> { // [-Infinity,-1) - // This page is way off-screen to the left. -// alpha = 0f - } - position <= 0 -> { // [-1,0] - // Use the default slide transition when moving to the left page - alpha = 1f - translationX = 0f - scaleX = 1f - scaleY = 1f - } - position <= 1 -> { // (0,1] - // Fade the page out. - alpha = 1 - position - - // Counteract the default slide transition - translationX = pageWidth * -position - - // Scale the page down (between MIN_SCALE and 1) - val scaleFactor = (MIN_SCALE + (1 - MIN_SCALE) * (1 - abs(position))) - scaleX = scaleFactor - scaleY = scaleFactor - } - else -> { // (1,+Infinity] - // This page is way off-screen to the right. - alpha = 0f - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/GallerySliderAdapter.kt b/app/src/main/java/app/grapheneos/camera/GallerySliderAdapter.kt deleted file mode 100644 index 25c8a1e6..00000000 --- a/app/src/main/java/app/grapheneos/camera/GallerySliderAdapter.kt +++ /dev/null @@ -1,153 +0,0 @@ -package app.grapheneos.camera - -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.ImageDecoder -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.RecyclerView -import app.grapheneos.camera.capturer.getVideoThumbnail -import app.grapheneos.camera.databinding.GallerySlideBinding -import app.grapheneos.camera.ui.ZoomableImageView -import app.grapheneos.camera.ui.activities.InAppGallery -import app.grapheneos.camera.ui.activities.VideoPlayer -import app.grapheneos.camera.ui.fragment.GallerySlide -import app.grapheneos.camera.util.executeIfAlive -import kotlin.math.max - -class GallerySliderAdapter( - private val gActivity: InAppGallery, - val items: ArrayList -) : RecyclerView.Adapter() { - - var atLeastOneBindViewHolderCall = false - - private val layoutInflater: LayoutInflater = LayoutInflater.from(gActivity) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GallerySlide { - return GallerySlide(GallerySlideBinding.inflate(layoutInflater, parent, false)) - } - - override fun getItemId(position: Int): Long { - return items[position].hashCode().toLong() - } - - override fun onBindViewHolder(holder: GallerySlide, position: Int) { - val mediaPreview: ZoomableImageView = holder.binding.slidePreview -// Log.d("GallerySliderAdapter", "postiion $position, preview ${System.identityHashCode(mediaPreview)}") - val playButton: ImageView = holder.binding.playButton - val item = items[position] - - mediaPreview.setGalleryActivity(gActivity) - mediaPreview.disableZooming() - mediaPreview.setOnClickListener(null) - mediaPreview.visibility = View.INVISIBLE - mediaPreview.setImageBitmap(null) - - val placeholderText = holder.binding.placeholderText.root - if (atLeastOneBindViewHolderCall) { - placeholderText.visibility = View.VISIBLE - placeholderText.setText("…") - } - atLeastOneBindViewHolderCall = true - - playButton.visibility = View.GONE - - holder.currentPostion = position - - gActivity.asyncImageLoader.executeIfAlive { - val bitmap: Bitmap? = try { - if (item.type == ITEM_TYPE_VIDEO) { - getVideoThumbnail(gActivity, item.uri) - } else { - val source = ImageDecoder.createSource(gActivity.contentResolver, item.uri) - ImageDecoder.decodeBitmap(source, ImageDownscaler) - } - } catch (e: Exception) { null } - - gActivity.mainExecutor.execute { - if (holder.currentPostion == position) { - if (bitmap != null) { - placeholderText.visibility = View.GONE - mediaPreview.visibility = View.VISIBLE - mediaPreview.setImageBitmap(bitmap) - - if (item.type == ITEM_TYPE_VIDEO) { - playButton.visibility = View.VISIBLE - } else if (item.type == ITEM_TYPE_IMAGE) { - mediaPreview.enableZooming() - } - - mediaPreview.setOnClickListener { - val curItem = getCurrentItem() - if (curItem.type == ITEM_TYPE_VIDEO) { - val intent = Intent(gActivity, VideoPlayer::class.java) - intent.putExtra(VideoPlayer.VIDEO_URI, curItem.uri) - intent.putExtra(VideoPlayer.IN_SECURE_MODE, gActivity.isSecureMode) - - gActivity.startActivity(intent) - } - } - } else { - mediaPreview.visibility = View.INVISIBLE - - val resId = if (item.type == ITEM_TYPE_IMAGE) { - R.string.inaccessible_image - } else { R.string.inaccessible_video } - - placeholderText.visibility = View.VISIBLE - placeholderText.setText(gActivity.getString(resId, item.dateString)) - } - } else { - bitmap?.recycle() - } - } - } - } - - fun removeItem(item: CapturedItem) { - removeChildAt(items.indexOf(item)) - } - - private fun removeChildAt(index: Int) { - items.removeAt(index) - - // Close gallery if no files are present - if (items.isEmpty()) { - gActivity.showMessage( - gActivity.getString(R.string.existing_no_image) - ) - gActivity.finish() - } - - notifyItemRemoved(index) - } - - fun getCurrentItem(): CapturedItem { - return items[gActivity.gallerySlider.currentItem] - } - - override fun getItemCount(): Int { - return items.size - } -} - -object ImageDownscaler : ImageDecoder.OnHeaderDecodedListener { - override fun onHeaderDecoded(decoder: ImageDecoder, - info: ImageDecoder.ImageInfo, source: ImageDecoder.Source) { - val size = info.size - val w = size.width - val h = size.height - // limit the max size of the bitmap to avoid bumping into bitmap size limit - // (100 MB) - val largerSide = max(w, h) - val maxSide = 4500 - - if (largerSide > maxSide) { - val ratio = maxSide.toDouble() / largerSide - decoder.setTargetSize((ratio * w).toInt(), (ratio * h).toInt()) - } - } -} diff --git a/app/src/main/java/app/grapheneos/camera/ui/ZoomableImageView.kt b/app/src/main/java/app/grapheneos/camera/ui/ZoomableImageView.kt deleted file mode 100644 index cfe4077d..00000000 --- a/app/src/main/java/app/grapheneos/camera/ui/ZoomableImageView.kt +++ /dev/null @@ -1,383 +0,0 @@ -package app.grapheneos.camera.ui - -import android.animation.Animator -import android.animation.Animator.AnimatorListener -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Matrix - -import android.view.ScaleGestureDetector - -import android.view.MotionEvent - -import android.graphics.PointF -import android.os.Handler -import android.os.Looper -import android.util.AttributeSet -import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener - -import androidx.appcompat.widget.AppCompatImageView -import app.grapheneos.camera.R -import app.grapheneos.camera.ui.activities.InAppGallery -import kotlin.math.abs - -class ZoomableImageView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : AppCompatImageView(context, attrs) { - - lateinit var mMatrix: Matrix - - private var mode = NONE - - private var last = PointF() - private var start = PointF() - private var minScale = 1f - private var maxScale = 3f - private var m: FloatArray = FloatArray(9) - private var viewWidth = 0 - private var viewHeight = 0 - private var saveScale = 1f - private var origWidth = 0f - private var origHeight = 0f - private var oldMeasuredWidth = 0 - private var oldMeasuredHeight = 0 - private var mScaleDetector: ScaleGestureDetector? = null - - private var isZoomingDisabled = true - - private var singleClickHandler = Handler(Looper.getMainLooper()) - private var singleClickRunnable = Runnable { - onSingleClick() - } - - private var scaleAnimator : ValueAnimator? = null - - lateinit var gActivity: InAppGallery - - init { - sharedConstructing() - } - - private val currentInstance: ZoomableImageView - get() { - return gActivity.gallerySlider.getChildAt(0) - .findViewById(R.id.slide_preview) - } - - fun setGalleryActivity(gActivity: InAppGallery) { - this.gActivity = gActivity - } - - fun enableZooming() { - isZoomingDisabled = false - } - - fun disableZooming() { - isZoomingDisabled = true - } - - @SuppressLint("ClickableViewAccessibility") - private fun sharedConstructing() { - super.setClickable(true) - mScaleDetector = ScaleGestureDetector(context, ScaleListener()) - mMatrix = Matrix() - imageMatrix = mMatrix - scaleType = ScaleType.MATRIX - - setOnTouchListener { _, event -> - - currentInstance.mScaleDetector!!.onTouchEvent(event) - - if (currentInstance.isZoomingDisabled) { - if (event.action == MotionEvent.ACTION_UP) { - this.onClickEvent(event) - return@setOnTouchListener performClick() - } - return@setOnTouchListener false - } - - val curr = PointF(event.x, event.y) - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentInstance.last.set(curr) - currentInstance.start.set(currentInstance.last) - currentInstance.mode = DRAG - } - MotionEvent.ACTION_MOVE -> if (currentInstance.mode == DRAG) { - val deltaX = curr.x - currentInstance.last.x - val deltaY = curr.y - currentInstance.last.y - - val fixTransX = currentInstance.getFixDragTrans( - deltaX, currentInstance.viewWidth.toFloat(), - currentInstance.origWidth - * currentInstance.saveScale - ) - - val fixTransY = currentInstance.getFixDragTrans( - deltaY, currentInstance.viewHeight.toFloat(), - currentInstance.origHeight * currentInstance.saveScale - ) - - currentInstance.mMatrix.postTranslate(fixTransX, fixTransY) - currentInstance.fixTrans() - currentInstance.last[curr.x] = curr.y - } - MotionEvent.ACTION_UP -> { - currentInstance.mode = NONE - val xDiff = abs(curr.x - currentInstance.start.x).toInt() - val yDiff = abs(curr.y - currentInstance.start.y).toInt() - if (xDiff < CLICK && yDiff < CLICK) { - currentInstance.onClickEvent(event) - performClick() - } - } - MotionEvent.ACTION_POINTER_UP -> currentInstance.mode = NONE - } - currentInstance.imageMatrix = currentInstance.mMatrix - currentInstance.invalidate() - true - } - } - - private var lastClickTimestampMs : Long = 0L - - private fun onClickEvent(event : MotionEvent) { - if ((event.eventTime - lastClickTimestampMs) <= DOUBLE_TAP_DELAY) { - singleClickHandler.removeCallbacks(singleClickRunnable) - onDoubleClick(event) - } else { - singleClickHandler.postDelayed(singleClickRunnable, DOUBLE_TAP_DELAY) - } - lastClickTimestampMs = event.eventTime - } - - private fun onSingleClick() { - gActivity.toggleUIState() - } - - private fun onDoubleClick(event: MotionEvent) { - // The user hasn't zoomed in - if (saveScale != 1f) { - scaleAnimator?.cancel() - scaleAnimator = ValueAnimator.ofFloat(saveScale, 1f) - scaleAnimator?.duration = SCALE_ANIMATION_DURATION - scaleAnimator?.addUpdateListener { - scaleImageTo(width / 2f, height / 2f, it.animatedValue as Float) - } - - scaleAnimator?.addListener(object: AnimatorListener { - - var isAnimationCancelled = false - - override fun onAnimationStart(animation: Animator) {} - - override fun onAnimationEnd(animation: Animator) { - if (!isAnimationCancelled) - gActivity.gallerySlider.isUserInputEnabled = true - } - - override fun onAnimationCancel(animation: Animator) { - isAnimationCancelled = true - } - - override fun onAnimationRepeat(animation: Animator) {} - - }) - - scaleAnimator?.start() - // Use value animator to animate from saveScale to 1f - } else { - // Something similar here - scaleAnimator?.cancel() - scaleAnimator = ValueAnimator.ofFloat(1f, DOUBLE_TAP_SCALE) - scaleAnimator?.duration = SCALE_ANIMATION_DURATION - scaleAnimator?.addUpdateListener { - scaleImageTo(event.x, event.y, it.animatedValue as Float) - } - scaleAnimator?.start() - } - } - - private fun scaleImageTo(pointX: Float, pointY: Float, newScale: Float) { - val scaleFactor = newScale / saveScale - scaleImageBy(pointX, pointY, scaleFactor) - } - - private fun scaleImageBy(pointX: Float, pointY: Float, scaleFactor: Float) { - - val origScale = saveScale - var sFactor = scaleFactor - saveScale *= sFactor - - if (saveScale > maxScale) { - saveScale = maxScale - sFactor = maxScale / origScale - } else if (saveScale < minScale) { - saveScale = minScale - sFactor = minScale / origScale - } - - if (origWidth * saveScale <= viewWidth - || origHeight * saveScale <= viewHeight - ) mMatrix.postScale( - sFactor, sFactor, viewWidth / 2f, - viewHeight / 2f - ) else mMatrix.postScale( - sFactor, sFactor, - pointX, pointY - ) - - fixTrans() - - if (saveScale == 1f) { - moveOutOfZoomMode() - } else { - moveIntoZoomMode() - } - - currentInstance.imageMatrix = currentInstance.mMatrix - currentInstance.invalidate() - } - - private inner class ScaleListener : SimpleOnScaleGestureListener() { - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { - mode = ZOOM - - // Cancel ongoing scaling animation (for e.g. on double tap) - scaleAnimator?.cancel() - - if (isZoomingDisabled) { - gActivity.showUI() - } else { - gActivity.gallerySlider.isUserInputEnabled = false - } - - return true - } - - override fun onScale(detector: ScaleGestureDetector): Boolean { - if (isZoomingDisabled) return true - scaleImageBy(detector.focusX, detector.focusY, detector.scaleFactor) - return true - } - - override fun onScaleEnd(detector: ScaleGestureDetector) { - super.onScaleEnd(detector) - if (saveScale == 1f) { - gActivity.gallerySlider.isUserInputEnabled = true - } - } - } - - private var isInZoomMode = false - - fun moveIntoZoomMode() { - - if (isInZoomMode) return - - isInZoomMode = true - - gActivity.let { - it.hideUI() - it.gallerySlider.isUserInputEnabled = false - } - } - - fun moveOutOfZoomMode() { - - if (!isInZoomMode) return - - isInZoomMode = false - - gActivity.showUI() - gActivity.vibrateDevice() - } - - fun fixTrans() { - - mMatrix.getValues(m) - val transX = m[Matrix.MTRANS_X] - val transY = m[Matrix.MTRANS_Y] - val fixTransX = getFixTrans(transX, viewWidth.toFloat(), origWidth * saveScale) - val fixTransY = getFixTrans( - transY, viewHeight.toFloat(), origHeight - * saveScale - ) - if (fixTransX != 0f || fixTransY != 0f) mMatrix.postTranslate(fixTransX, fixTransY) - } - - private fun getFixTrans(trans: Float, viewSize: Float, contentSize: Float): Float { - - val minTrans: Float - val maxTrans: Float - if (contentSize <= viewSize) { - minTrans = 0f - maxTrans = viewSize - contentSize - } else { - minTrans = viewSize - contentSize - maxTrans = 0f - } - if (trans < minTrans) return -trans + minTrans - return if (trans > maxTrans) -trans + maxTrans else 0f - } - - private fun getFixDragTrans(delta: Float, viewSize: Float, contentSize: Float): Float { - return if (contentSize <= viewSize) { - 0f - } else delta - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - viewWidth = MeasureSpec.getSize(widthMeasureSpec) - viewHeight = MeasureSpec.getSize(heightMeasureSpec) - - // Rescales image on rotation - if (oldMeasuredHeight == viewWidth && - oldMeasuredHeight == viewHeight || viewWidth == 0 || - viewHeight == 0 - ) return - - oldMeasuredHeight = viewHeight - oldMeasuredWidth = viewWidth - - if (saveScale == 1f) { - // Fit to screen. - val scale: Float - if (drawable?.intrinsicWidth ?: 0 == 0 || drawable?.intrinsicHeight ?: 0 == 0) return - val bmWidth = drawable.intrinsicWidth - val bmHeight = drawable.intrinsicHeight -// Log.d("bmSize", "bmWidth: $bmWidth bmHeight : $bmHeight") - val scaleX = viewWidth.toFloat() / bmWidth.toFloat() - val scaleY = viewHeight.toFloat() / bmHeight.toFloat() - scale = scaleX.coerceAtMost(scaleY) - mMatrix.setScale(scale, scale) - - // Center the image - var redundantYSpace = viewHeight.toFloat() - scale * bmHeight.toFloat() - var redundantXSpace = viewWidth.toFloat() - scale * bmWidth.toFloat() - redundantYSpace /= 2f - redundantXSpace /= 2f - mMatrix.postTranslate(redundantXSpace, redundantYSpace) - origWidth = viewWidth - 2 * redundantXSpace - origHeight = viewHeight - 2 * redundantYSpace - imageMatrix = mMatrix - } - fixTrans() - } - - companion object { - const val NONE = 0 - const val DRAG = 1 - const val ZOOM = 2 - const val CLICK = 3 - - private const val DOUBLE_TAP_DELAY = 200L - private const val DOUBLE_TAP_SCALE = 1.75F - - private const val SCALE_ANIMATION_DURATION = 300L - } -} diff --git a/app/src/main/java/app/grapheneos/camera/ui/fragment/GallerySlide.kt b/app/src/main/java/app/grapheneos/camera/ui/fragment/GallerySlide.kt deleted file mode 100644 index 0fb38615..00000000 --- a/app/src/main/java/app/grapheneos/camera/ui/fragment/GallerySlide.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.grapheneos.camera.ui.fragment - -import androidx.recyclerview.widget.RecyclerView -import app.grapheneos.camera.databinding.GallerySlideBinding - -class GallerySlide(val binding: GallerySlideBinding) : RecyclerView.ViewHolder(binding.root) { - @Volatile var currentPostion = 0 -} diff --git a/app/src/main/res/layout/gallery.xml b/app/src/main/res/layout/gallery.xml deleted file mode 100644 index db514feb..00000000 --- a/app/src/main/res/layout/gallery.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/gallery_placeholder.xml b/app/src/main/res/layout/gallery_placeholder.xml deleted file mode 100644 index fcd658f7..00000000 --- a/app/src/main/res/layout/gallery_placeholder.xml +++ /dev/null @@ -1,10 +0,0 @@ - - diff --git a/app/src/main/res/layout/gallery_slide.xml b/app/src/main/res/layout/gallery_slide.xml deleted file mode 100644 index 31e09a8c..00000000 --- a/app/src/main/res/layout/gallery_slide.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/video_player.xml b/app/src/main/res/layout/video_player.xml deleted file mode 100644 index e06e22d9..00000000 --- a/app/src/main/res/layout/video_player.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/menu/gallery.xml b/app/src/main/res/menu/gallery.xml deleted file mode 100644 index b7df8f02..00000000 --- a/app/src/main/res/menu/gallery.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - From f1005e9f86b6f63f16c1fa22f785b3bac0eabb28 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 17 Feb 2025 01:35:59 +0530 Subject: [PATCH 21/31] Add verification metadata --- gradle/verification-metadata.xml | 10221 ++++++++++++++++++----------- 1 file changed, 6496 insertions(+), 3725 deletions(-) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index d88cfd59..b4c924c8 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1,3728 +1,6499 @@ - - true - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f5778f83a7f0687d7561038713146d6d0a62c0c8 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Thu, 28 Nov 2024 01:35:04 +0530 Subject: [PATCH 22/31] Use no action bar theme (for InAppGallery and VideoPlayer activities) --- app/src/main/AndroidManifest.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f40f6704..bfe2f944 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -168,7 +168,6 @@ Date: Thu, 28 Nov 2024 14:33:29 +0530 Subject: [PATCH 23/31] Add immersive experience in focus mode of InAppGallery --- .../main/java/app/grapheneos/camera/ktx/Dp.kt | 3 ++ .../ui/composable/screen/ui/GalleryScreen.kt | 28 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt b/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt index 701511b8..1509cb9c 100644 --- a/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt +++ b/app/src/main/java/app/grapheneos/camera/ktx/Dp.kt @@ -4,5 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp +@Composable +fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } + @Composable fun Dp.toPx() = with(LocalDensity.current) { this@toPx.toPx() } \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt index 1751f086..863ee256 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt @@ -1,5 +1,6 @@ package app.grapheneos.camera.ui.composable.screen.ui +import android.app.Activity import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -26,6 +27,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,11 +39,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.viewmodel.compose.viewModel import app.grapheneos.camera.CapturedItem @@ -74,6 +80,12 @@ fun GalleryScreen( ) { val context = LocalContext.current + val window = (context as Activity).window + + val insetsController = WindowCompat.getInsetsController(window, LocalView.current).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + } + val zoomableState = rememberZoomableState() val coroutineScope = rememberCoroutineScope() @@ -125,6 +137,20 @@ fun GalleryScreen( } } + LaunchedEffect(viewModel.inFocusMode) { + if (viewModel.inFocusMode) { + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + } else { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } + } + + DisposableEffect(Unit) { + onDispose { + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } + } + // Displays media info dialog when displayedMediaItem is not null MediaInfoDialog( mediaItemDetails = viewModel.displayedMediaItem, @@ -224,7 +250,7 @@ fun GalleryScreen( // material guidelines) 0.dp, innerPadding.calculateEndPadding(LayoutDirection.Ltr), - innerPadding.calculateBottomPadding(), + 0.dp, ) .fillMaxSize() ) { page -> From 62fd8d37fa1528991a67918c3e29bfa5308148cf Mon Sep 17 00:00:00 2001 From: MHShetty Date: Sat, 30 Nov 2024 04:23:30 +0530 Subject: [PATCH 24/31] Separate UI-specific state from view model --- .../camera/ktx/SnackbarHostState.kt | 6 ++- .../component/SnackBarMessageHandler.kt | 23 +++++++++++ .../ui/composable/model/SnackBarMessage.kt | 10 +++++ .../screen/ui/ExtendedGalleryScreen.kt | 14 ++++++- .../ui/composable/screen/ui/GalleryScreen.kt | 27 ++++++++++--- .../viewmodel/ExtendedGalleryViewModel.kt | 35 ++++++++--------- .../screen/viewmodel/GalleryViewModel.kt | 38 ++++++++++--------- 7 files changed, 110 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/component/SnackBarMessageHandler.kt create mode 100644 app/src/main/java/app/grapheneos/camera/ui/composable/model/SnackBarMessage.kt diff --git a/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt b/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt index 28d38704..adeff6b2 100644 --- a/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt +++ b/app/src/main/java/app/grapheneos/camera/ktx/SnackbarHostState.kt @@ -10,11 +10,15 @@ suspend fun SnackbarHostState.showOrReplaceSnackbar( duration: SnackbarDuration = if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite ) { - currentSnackbarData?.dismiss() + dismissSnackBarIfVisible() showSnackbar( message = message, actionLabel = actionLabel, withDismissAction = withDismissAction, duration = duration ) +} + +fun SnackbarHostState.dismissSnackBarIfVisible() { + currentSnackbarData?.dismiss() } \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/component/SnackBarMessageHandler.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/component/SnackBarMessageHandler.kt new file mode 100644 index 00000000..1ca980c7 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/component/SnackBarMessageHandler.kt @@ -0,0 +1,23 @@ +package app.grapheneos.camera.ui.composable.component + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import app.grapheneos.camera.ktx.dismissSnackBarIfVisible +import app.grapheneos.camera.ktx.showOrReplaceSnackbar +import app.grapheneos.camera.ui.composable.model.NoDataSnackBarMessage +import app.grapheneos.camera.ui.composable.model.SnackBarMessage + +@Composable +fun SnackBarMessageHandler( + snackBarHostState: SnackbarHostState, + snackBarMessage: SnackBarMessage +) { + LaunchedEffect(snackBarMessage) { + if (snackBarMessage == NoDataSnackBarMessage) { + snackBarHostState.dismissSnackBarIfVisible() + } else { + snackBarHostState.showOrReplaceSnackbar(snackBarMessage.message) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/model/SnackBarMessage.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/model/SnackBarMessage.kt new file mode 100644 index 00000000..3653f990 --- /dev/null +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/model/SnackBarMessage.kt @@ -0,0 +1,10 @@ +package app.grapheneos.camera.ui.composable.model + +import java.util.UUID + +data class SnackBarMessage( + val message: String, + val id: UUID = UUID.randomUUID() +) + +val NoDataSnackBarMessage = SnackBarMessage("") diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt index 75ba15ec..267003f1 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt @@ -27,8 +27,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -43,6 +45,7 @@ import app.grapheneos.camera.CapturedItem import app.grapheneos.camera.R import app.grapheneos.camera.ktx.header import app.grapheneos.camera.ktx.requestDeviceUnlock +import app.grapheneos.camera.ui.composable.component.SnackBarMessageHandler import app.grapheneos.camera.ui.composable.component.dialog.MultipleFileDeletionDialog import app.grapheneos.camera.ui.composable.component.mediapreview.SQUARE_MEDIA_PREVIEW_SIZE import app.grapheneos.camera.ui.composable.component.mediapreview.SquareMediaPreview @@ -62,6 +65,15 @@ fun ExtendedGalleryScreen( ExtendedGalleryViewModel(context) } + val snackBarHostState = remember { + SnackbarHostState() + } + + SnackBarMessageHandler( + snackBarHostState = snackBarHostState, + snackBarMessage = viewModel.snackBarMessage, + ) + BackHandler { if (viewModel.selectMode) { viewModel.exitSelectionMode() @@ -83,7 +95,7 @@ fun ExtendedGalleryScreen( Scaffold ( snackbarHost = { - SnackbarHost(viewModel.snackBarHostState) + SnackbarHost(snackBarHostState) }, topBar = { diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt index 863ee256..31edd345 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -24,6 +25,7 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -63,6 +65,7 @@ import me.saket.telephoto.zoomable.zoomable import app.grapheneos.camera.ITEM_TYPE_IMAGE import app.grapheneos.camera.R +import app.grapheneos.camera.ui.composable.component.SnackBarMessageHandler import app.grapheneos.camera.ui.composable.component.tooltip.QuickTooltip import app.grapheneos.camera.ui.composable.component.tooltip.QuickTooltipVerticalDirection @@ -90,10 +93,23 @@ fun GalleryScreen( val coroutineScope = rememberCoroutineScope() + val snackBarHostState = remember { + SnackbarHostState() + } + val viewModel = viewModel { GalleryViewModel(context) } + val pagerState = rememberPagerState { + viewModel.capturedItems.size + } + + SnackBarMessageHandler( + snackBarHostState = snackBarHostState, + snackBarMessage = viewModel.snackBarMessage + ) + val backgroundColor by animateColorAsState( label = "background_color_animation", targetValue = if (viewModel.inFocusMode) Color.Black else AppColor.BackgroundColor, @@ -111,7 +127,7 @@ fun GalleryScreen( val focusIndex = viewModel.capturedItems.indexOf(focusItem) if (focusIndex != -1) { - viewModel.pagerState.scrollToPage(focusIndex) + pagerState.scrollToPage(focusIndex) } } @@ -121,8 +137,9 @@ fun GalleryScreen( } // Update the current focus item when the user slides between pages - LaunchedEffect(viewModel.pagerState.currentPage) { - val page = viewModel.pagerState.currentPage + LaunchedEffect(pagerState.currentPage) { + val page = pagerState.currentPage + viewModel.currentPage = page if (page < viewModel.capturedItems.size) { viewModel.focusItem = viewModel.capturedItems[page] } @@ -169,7 +186,7 @@ fun GalleryScreen( Scaffold( containerColor = backgroundColor, - snackbarHost = { SnackbarHost(hostState = viewModel.snackbarHostState) }, + snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, floatingActionButton = { AnimatedVisibility( @@ -239,7 +256,7 @@ fun GalleryScreen( } else { if (viewModel.hasCapturedItems) { HorizontalPager( - state = viewModel.pagerState, + state = pagerState, userScrollEnabled = !viewModel.isZoomedIn, beyondViewportPageCount = 1, modifier = Modifier diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt index 4b24f331..6ece2b3e 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/ExtendedGalleryViewModel.kt @@ -9,13 +9,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.lifecycle.ViewModel -import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.viewModelScope import app.grapheneos.camera.CapturedItem import app.grapheneos.camera.R import app.grapheneos.camera.ktx.isDeviceLocked -import app.grapheneos.camera.ktx.showOrReplaceSnackbar +import app.grapheneos.camera.ui.composable.model.NoDataSnackBarMessage +import app.grapheneos.camera.ui.composable.model.SnackBarMessage import app.grapheneos.camera.util.getMimeTypeForItems import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -58,14 +58,21 @@ class ExtendedGalleryViewModel(context: Context) : ViewModel() { var isDeletionDialogVisible by mutableStateOf(false) - val snackBarHostState = SnackbarHostState() + var snackBarMessage by mutableStateOf(NoDataSnackBarMessage) + private set + + fun showSnackBar(message: String) { + snackBarMessage = SnackBarMessage(message) + } + + fun hideSnackBar() { + snackBarMessage = NoDataSnackBarMessage + } fun showDeletionDialog(context: Context) { viewModelScope.launch { if (selectedItems.isEmpty()) { - snackBarHostState.showOrReplaceSnackbar( - context.getString(R.string.select_an_item_request) - ) + showSnackBar(context.getString(R.string.select_an_item_request)) return@launch } isDeletionDialogVisible = true @@ -165,16 +172,12 @@ class ExtendedGalleryViewModel(context: Context) : ViewModel() { fun shareSelectedItems(context: Context) { viewModelScope.launch { if (context.isDeviceLocked()) { - snackBarHostState.showOrReplaceSnackbar( - context.getString(R.string.sharing_not_allowed) - ) + showSnackBar(context.getString(R.string.sharing_not_allowed)) return@launch } if (selectedItems.isEmpty()) { - snackBarHostState.showOrReplaceSnackbar( - context.getString(R.string.select_an_item_request) - ) + showSnackBar(context.getString(R.string.select_an_item_request)) return@launch } @@ -218,13 +221,7 @@ class ExtendedGalleryViewModel(context: Context) : ViewModel() { } if (failedDeletions != 0) { - snackBarHostState.showOrReplaceSnackbar( - context.getString( - R.string.failed_multiple_deletion_message, - failedDeletions, - selectedItemsSize - ) - ) + showSnackBar(context.getString(R.string.failed_multiple_deletion_message, failedDeletions, selectedItemsSize)) } } } diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt index 19f25b0b..3ec07055 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/viewmodel/GalleryViewModel.kt @@ -6,8 +6,6 @@ import android.content.Intent import android.util.Log import android.widget.Toast -import androidx.compose.foundation.pager.PagerState -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -21,8 +19,9 @@ import androidx.lifecycle.viewModelScope import app.grapheneos.camera.CapturedItem import app.grapheneos.camera.R import app.grapheneos.camera.ktx.isDeviceLocked -import app.grapheneos.camera.ktx.showOrReplaceSnackbar import app.grapheneos.camera.ui.composable.model.MediaItemDetails +import app.grapheneos.camera.ui.composable.model.NoDataSnackBarMessage +import app.grapheneos.camera.ui.composable.model.SnackBarMessage import kotlinx.coroutines.launch private const val TAG = "GalleryViewModel" @@ -39,8 +38,10 @@ class GalleryViewModel(context: Context) : ViewModel() { var deletionItem by mutableStateOf(null) + var currentPage = 0 + private val currentItem: CapturedItem - get() = capturedItems[pagerState.currentPage] + get() = capturedItems[currentPage] private val capturedItemsViewModel = CapturedItemsRepository.get(context) @@ -54,18 +55,21 @@ class GalleryViewModel(context: Context) : ViewModel() { capturedItemsViewModel.isLoading } - val snackbarHostState = SnackbarHostState() + var snackBarMessage by mutableStateOf(NoDataSnackBarMessage) + private set - val pagerState = PagerState( - pageCount = { - capturedItems.size - } - ) + fun showSnackBar(message: String) { + snackBarMessage = SnackBarMessage(message) + } + + fun hideSnackBar() { + snackBarMessage = NoDataSnackBarMessage + } fun displayMediaInfo(context: Context, item: CapturedItem = currentItem) { viewModelScope.launch { if (!hasCapturedItems) { - snackbarHostState.showOrReplaceSnackbar( + showSnackBar( context.getString(R.string.unable_to_obtain_file_details) ) return@launch @@ -76,7 +80,7 @@ class GalleryViewModel(context: Context) : ViewModel() { } catch (e: Exception) { Log.i(TAG, "Unable to obtain file details for MediaInfoDialog") e.printStackTrace() - snackbarHostState.showOrReplaceSnackbar( + showSnackBar( context.getString(R.string.unable_to_obtain_file_details) ) } @@ -112,12 +116,12 @@ class GalleryViewModel(context: Context) : ViewModel() { .show() onLastItemDeletion() } else { - snackbarHostState.showOrReplaceSnackbar( + showSnackBar( context.getString(R.string.deleted_successfully) ) } } else { - snackbarHostState.showOrReplaceSnackbar( + showSnackBar( context.getString(R.string.deleting_unexpected_error) ) } @@ -138,7 +142,7 @@ class GalleryViewModel(context: Context) : ViewModel() { ) { viewModelScope.launch { if (context.isDeviceLocked()) { - snackbarHostState.showOrReplaceSnackbar( + showSnackBar( context.getString(R.string.edit_not_allowed) ) return@launch @@ -166,7 +170,7 @@ class GalleryViewModel(context: Context) : ViewModel() { try { context.startActivity(editIntent) } catch (ignored: ActivityNotFoundException) { - snackbarHostState.showOrReplaceSnackbar( + showSnackBar( context.getString(R.string.no_editor_app_error) ) } @@ -180,7 +184,7 @@ class GalleryViewModel(context: Context) : ViewModel() { ) { viewModelScope.launch { if (context.isDeviceLocked()) { - snackbarHostState.showOrReplaceSnackbar( + showSnackBar( context.getString(R.string.sharing_not_allowed) ) return@launch From 43115e69edd3f2dfbc064f3fb5ac71ab9a601292 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Sat, 30 Nov 2024 04:43:27 +0530 Subject: [PATCH 25/31] Add missing color (the color was removed in a different PR but is continued to being used here) --- app/src/main/res/values/colors.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index cf37f034..5f23e813 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,7 @@ #cbe2ff + #1b69c5 #dde2e9 #181c1f From 1059d485d8f68f5548d1a7700b5e22306a76d13c Mon Sep 17 00:00:00 2001 From: MHShetty Date: Tue, 3 Dec 2024 20:38:30 +0530 Subject: [PATCH 26/31] Disable predictive back gesture for MainActivity (currently causes unexpected UI behavior; can be dealt with while migrating the rest of the camera app to Compose) --- app/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bfe2f944..0678585d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,7 +29,6 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.App" - android:enableOnBackInvokedCallback="true" tools:ignore="UnusedAttribute"> From 8f4a647278bcbf3d123905d6b2f17a9a777f6525 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Tue, 3 Dec 2024 20:52:52 +0530 Subject: [PATCH 27/31] Push new gallery screen (when clicking on an image in extended gallery) --- .../camera/ui/activities/InAppGallery.kt | 2 +- .../camera/ui/composable/CameraApp.kt | 15 +++--- .../screen/routes/ExtendedGalleryRoute.kt | 10 ++-- .../composable/screen/routes/GalleryRoute.kt | 29 +++++------- .../ui/composable/screen/ui/GalleryScreen.kt | 46 ++++++++++--------- 5 files changed, 49 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt index 50142db8..bce0cf2c 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/activities/InAppGallery.kt @@ -86,7 +86,7 @@ class InAppGallery : AppCompatActivity() { setContent { CameraApp( - initialRoute = GalleryRoute, + initialRoute = GalleryRoute(), onExitAction = this::finish, ) } diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt index a6421d09..690e7f53 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/CameraApp.kt @@ -50,13 +50,12 @@ fun CameraApp( ExitTransition.None } ) { - composable { backStackEntry -> + composable(typeMap = GalleryRoute.typeMap) { backStackEntry -> - val focusItem = backStackEntry.savedStateHandle - .get("FOCUS_ITEM") + val args = backStackEntry.toRoute() GalleryScreen( - focusItem = focusItem, + focusItem = args.focusItem, showVideoPlayerAction = { capturedItem -> navController.navigate( VideoPlayerRoute(videoUri = VideoUri(capturedItem.uri)) @@ -79,9 +78,11 @@ fun CameraApp( ) ) } else { - navController.previousBackStackEntry?.savedStateHandle - ?.set("FOCUS_ITEM", capturedItem) - navController.popBackStack(GalleryRoute, false) + navController.navigate( + GalleryRoute( + focusItem = capturedItem + ) + ) } }, diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt index 816e23a1..8a08e272 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/ExtendedGalleryRoute.kt @@ -6,19 +6,17 @@ import kotlinx.serialization.Serializable object ExtendedGalleryRoute //import app.grapheneos.camera.CapturedItem -//import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemListNavType //import kotlin.reflect.typeOf //import kotlinx.serialization.Serializable -// +//import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemNavType + //@Serializable //data class ExtendedGalleryRoute( -// val isSecureMode : Boolean = false, -// val showVideosOnly : Boolean = false, -// val mediaItems : List? = null, +// val focusItem: CapturedItem? = null //) { // companion object { // val typeMap = mapOf( -// typeOf?>() to CapturedItemListNavType, +// typeOf?>() to CapturedItemNavType, // ) // } //} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt index 9306c2e5..7758fd37 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/routes/GalleryRoute.kt @@ -1,25 +1,20 @@ package app.grapheneos.camera.ui.composable.screen.routes import app.grapheneos.camera.CapturedItem -import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemListNavType import app.grapheneos.camera.ui.composable.screen.navtype.CapturedItemNavType import kotlin.reflect.typeOf import kotlinx.serialization.Serializable -@Serializable -object GalleryRoute - //@Serializable -//data class GalleryRoute( -// val isSecureMode : Boolean = false, -// val showVideosOnly : Boolean = false, -// val lastCapturedItem : CapturedItem? = null, -// val mediaItems : List? = null, -//) { -// companion object { -// val typeMap = mapOf( -// typeOf() to CapturedItemNavType, -// typeOf?>() to CapturedItemListNavType, -// ) -// } -//} \ No newline at end of file +//object GalleryRoute + +@Serializable +data class GalleryRoute( + val focusItem: CapturedItem? = null +) { + companion object { + val typeMap = mapOf( + typeOf() to CapturedItemNavType, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt index 31edd345..2a23ffbe 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt @@ -189,30 +189,32 @@ fun GalleryScreen( snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, floatingActionButton = { - AnimatedVisibility( - visible = !viewModel.inFocusMode, - enter = fadeIn(), - exit = fadeOut() - ) { - QuickTooltip( - message = stringResource(R.string.search_images), - defaultDirection = QuickTooltipVerticalDirection.TOP + if (focusItem == null) { + AnimatedVisibility( + visible = !viewModel.inFocusMode, + enter = fadeIn(), + exit = fadeOut() ) { - FloatingActionButton( - onClick = { - coroutineScope.launch { - if (zoomableState.zoomFraction != 0f) { - zoomableState.resetZoom() - } - showExtendedGalleryAction() - } - }, - shape = CircleShape, + QuickTooltip( + message = stringResource(R.string.search_images), + defaultDirection = QuickTooltipVerticalDirection.TOP ) { - Icon( - Icons.Default.Search, - contentDescription = stringResource(R.string.search_images), - ) + FloatingActionButton( + onClick = { + coroutineScope.launch { + if (zoomableState.zoomFraction != 0f) { + zoomableState.resetZoom() + } + showExtendedGalleryAction() + } + }, + shape = CircleShape, + ) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(R.string.search_images), + ) + } } } } From db2bdde61732ddae49c03f3a1f4afe7b4c793b61 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Tue, 3 Dec 2024 20:55:22 +0530 Subject: [PATCH 28/31] Replace search icon with grid icon (in gallery FAB) --- .../camera/ui/composable/screen/ui/GalleryScreen.kt | 6 +++--- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt index 2a23ffbe..87c73f39 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt @@ -20,7 +20,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.GridOn import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold @@ -196,7 +196,7 @@ fun GalleryScreen( exit = fadeOut() ) { QuickTooltip( - message = stringResource(R.string.search_images), + message = stringResource(R.string.grid_view), defaultDirection = QuickTooltipVerticalDirection.TOP ) { FloatingActionButton( @@ -211,7 +211,7 @@ fun GalleryScreen( shape = CircleShape, ) { Icon( - Icons.Default.Search, + Icons.Default.GridOn, contentDescription = stringResource(R.string.search_images), ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eff3fc37..f1455d1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -223,4 +223,6 @@ The video\'s audio recording has been muted The video\'s audio recording has been unmuted + + Grid View From 2b4b1a034a3ec7140e7d7eaf21844945eb0b5a32 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Tue, 3 Dec 2024 20:57:08 +0530 Subject: [PATCH 29/31] Clear snackbar message on dispose --- .../ui/composable/screen/ui/ExtendedGalleryScreen.kt | 8 ++++++++ .../camera/ui/composable/screen/ui/GalleryScreen.kt | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt index 267003f1..f641da1a 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/ExtendedGalleryScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -74,6 +75,13 @@ fun ExtendedGalleryScreen( snackBarMessage = viewModel.snackBarMessage, ) + // Clear snackbar message on dispose + DisposableEffect(Unit) { + onDispose { + viewModel.hideSnackBar() + } + } + BackHandler { if (viewModel.selectMode) { viewModel.exitSelectionMode() diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt index 87c73f39..23b68b7a 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/GalleryScreen.kt @@ -110,6 +110,12 @@ fun GalleryScreen( snackBarMessage = viewModel.snackBarMessage ) + DisposableEffect(Unit) { + onDispose { + viewModel.hideSnackBar() + } + } + val backgroundColor by animateColorAsState( label = "background_color_animation", targetValue = if (viewModel.inFocusMode) Color.Black else AppColor.BackgroundColor, From 671f740f4b7e512afc7c2629670cd89c1597ba45 Mon Sep 17 00:00:00 2001 From: MHShetty Date: Tue, 3 Dec 2024 20:57:27 +0530 Subject: [PATCH 30/31] Reduce controllerShowTimeoutMs of VideoPlayer --- .../camera/ui/composable/screen/ui/VideoPlayerScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt index f6b3892f..e258aa3a 100644 --- a/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt +++ b/app/src/main/java/app/grapheneos/camera/ui/composable/screen/ui/VideoPlayerScreen.kt @@ -134,7 +134,7 @@ fun VideoPlayerScreen( factory = { context -> PlayerView(context).apply { player = exoPlayer - controllerShowTimeoutMs = 2000 + controllerShowTimeoutMs = 1200 } } ) From 38882ba3e45a0617768c34a7ee3007dffbcac6bf Mon Sep 17 00:00:00 2001 From: MHShetty Date: Mon, 17 Feb 2025 17:56:42 +0530 Subject: [PATCH 31/31] Add verification metadata --- gradle/verification-metadata.xml | 13080 +++++++++++++++-------------- 1 file changed, 6584 insertions(+), 6496 deletions(-) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index b4c924c8..1e2f7fbe 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1,6499 +1,6587 @@ - - true - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +