diff --git a/core/testing/src/main/java/com/android/developers/testing/repository/FakeDropImageFactory.kt b/core/testing/src/main/java/com/android/developers/testing/repository/FakeDropImageFactory.kt new file mode 100644 index 00000000..783d349b --- /dev/null +++ b/core/testing/src/main/java/com/android/developers/testing/repository/FakeDropImageFactory.kt @@ -0,0 +1,24 @@ +package com.android.developers.testing.repository + +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import com.android.developers.androidify.data.DropBehaviourFactory + +class FakeDropImageFactory : DropBehaviourFactory { + override fun shouldStartDragAndDrop(event: DragAndDropEvent): Boolean = true + + override fun createTargetCallback( + activity: ComponentActivity, + onImageDropped: (Uri) -> Unit, + onDropStarted: () -> Unit, + onDropEnded: () -> Unit, + ): DragAndDropTarget = object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + return false + } + + } + +} \ No newline at end of file diff --git a/data/src/main/java/com/android/developers/androidify/data/DataModule.kt b/data/src/main/java/com/android/developers/androidify/data/DataModule.kt index 6ffb15d1..883bbe4a 100644 --- a/data/src/main/java/com/android/developers/androidify/data/DataModule.kt +++ b/data/src/main/java/com/android/developers/androidify/data/DataModule.kt @@ -42,7 +42,10 @@ internal object DataModule { @Provides @Singleton - fun provideLocalFileProvider(@ApplicationContext appContext: Context, @Named("IO") ioDispatcher: CoroutineDispatcher): LocalFileProvider = + fun provideLocalFileProvider( + @ApplicationContext appContext: Context, + @Named("IO") ioDispatcher: CoroutineDispatcher, + ): LocalFileProvider = LocalFileProviderImpl(appContext, ioDispatcher) @Provides @@ -101,4 +104,8 @@ internal object DataModule { internetConnectivityManager = internetConnectivityManager, firebaseAiDataSource = firebaseAiDataSource, ) + + @Provides + fun dropBehaviourFactory(localFileProvider: LocalFileProvider,): DropBehaviourFactory = + DropBehaviourFactoryImpl(localFileProvider = localFileProvider) } diff --git a/data/src/main/java/com/android/developers/androidify/data/DropBehaviourFactory.kt b/data/src/main/java/com/android/developers/androidify/data/DropBehaviourFactory.kt new file mode 100644 index 00000000..944caf1d --- /dev/null +++ b/data/src/main/java/com/android/developers/androidify/data/DropBehaviourFactory.kt @@ -0,0 +1,86 @@ +package com.android.developers.androidify.data + +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.mimeTypes +import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.lifecycle.lifecycleScope +import com.android.developers.androidify.util.LocalFileProvider +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + + +interface DropBehaviourFactory { + fun shouldStartDragAndDrop(event: DragAndDropEvent): Boolean + fun createTargetCallback( + activity: ComponentActivity, + onImageDropped: (Uri) -> Unit, + onDropStarted: () -> Unit = {}, + onDropEnded: () -> Unit = {}, + ): DragAndDropTarget +} + +class DropBehaviourFactoryImpl @Inject constructor(val localFileProvider: LocalFileProvider) : + DropBehaviourFactory { + + override fun shouldStartDragAndDrop(event: DragAndDropEvent): Boolean = + event.mimeTypes().contains("image/jpeg") + + override fun createTargetCallback( + activity: ComponentActivity, + onImageDropped: (Uri) -> Unit, + onDropStarted: () -> Unit, + onDropEnded: () -> Unit, + ) = + object : DragAndDropTarget { + override fun onStarted(event: DragAndDropEvent) { + super.onStarted(event) + onDropStarted() + } + + override fun onEnded(event: DragAndDropEvent) { + super.onEnded(event) + onDropEnded() + } + + /** + * Dropping an image requires the app to obtain the permission to use the image being + * dropped. This permission only lasts until the event is completed. The easiest way + * of being able to display the image being dropped is to temporarily copy it inside + * the app storage and use that copy for the processing. + */ + override fun onDrop(event: DragAndDropEvent): Boolean { + val targetEvent = event.toAndroidDragEvent() + + if (targetEvent.clipData.itemCount == 0) { + return false + } + + activity.lifecycleScope.launch { + val permission = activity.requestDragAndDropPermissions(targetEvent) + if (permission != null) { + try { + val inputUri = targetEvent.clipData.getItemAt(0).uri + activity.contentResolver.openInputStream(inputUri)?.use { inputStream -> + val bitmap = BitmapFactory.decodeStream(inputStream) + + bitmap?.let { + val cacheFile = + localFileProvider.createCacheFile("dropped_image_${UUID.randomUUID()}.jpg") + localFileProvider.saveBitmapToFile(bitmap, cacheFile) + onImageDropped(localFileProvider.sharingUriForFile(cacheFile)) + } + } + } finally { + permission.release() + } + } + } + return true + } + } +} \ No newline at end of file diff --git a/feature/creation/src/androidTest/java/com/android/developers/androidify/creation/CreationScreenTest.kt b/feature/creation/src/androidTest/java/com/android/developers/androidify/creation/CreationScreenTest.kt index 46624d90..3e804199 100644 --- a/feature/creation/src/androidTest/java/com/android/developers/androidify/creation/CreationScreenTest.kt +++ b/feature/creation/src/androidTest/java/com/android/developers/androidify/creation/CreationScreenTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.developers.androidify.theme.SharedElementContextPreview +import com.android.developers.testing.repository.FakeDropImageFactory import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -45,6 +46,7 @@ class CreationScreenTest { SharedElementContextPreview { EditScreen( snackbarHostState = SnackbarHostState(), + dropBehaviourFactory = FakeDropImageFactory(), isExpanded = false, onCameraPressed = {}, onBackPressed = {}, @@ -73,6 +75,7 @@ class CreationScreenTest { SharedElementContextPreview { EditScreen( snackbarHostState = SnackbarHostState(), + dropBehaviourFactory = FakeDropImageFactory(), isExpanded = false, onCameraPressed = {}, onBackPressed = {}, @@ -102,6 +105,7 @@ class CreationScreenTest { SharedElementContextPreview { EditScreen( snackbarHostState = SnackbarHostState(), + dropBehaviourFactory = FakeDropImageFactory(), isExpanded = false, onCameraPressed = {}, onBackPressed = {}, @@ -136,6 +140,7 @@ class CreationScreenTest { SharedElementContextPreview { EditScreen( snackbarHostState = SnackbarHostState(), + dropBehaviourFactory = FakeDropImageFactory(), isExpanded = false, onCameraPressed = {}, onBackPressed = {}, @@ -169,6 +174,7 @@ class CreationScreenTest { SharedElementContextPreview { EditScreen( snackbarHostState = SnackbarHostState(), + dropBehaviourFactory = FakeDropImageFactory(), isExpanded = false, onCameraPressed = {}, onBackPressed = {}, @@ -203,6 +209,7 @@ class CreationScreenTest { SharedElementContextPreview { EditScreen( snackbarHostState = SnackbarHostState(), + dropBehaviourFactory = FakeDropImageFactory(), isExpanded = false, onCameraPressed = {}, onBackPressed = {}, @@ -236,6 +243,7 @@ class CreationScreenTest { SharedElementContextPreview { EditScreen( snackbarHostState = SnackbarHostState(), + dropBehaviourFactory = FakeDropImageFactory(), isExpanded = false, onCameraPressed = {}, onBackPressed = {}, @@ -265,6 +273,7 @@ class CreationScreenTest { SharedElementContextPreview { EditScreen( snackbarHostState = SnackbarHostState(), + dropBehaviourFactory = FakeDropImageFactory(), isExpanded = true, // Expanded mode onCameraPressed = {}, onBackPressed = {}, diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt index ca80bde4..e55c684f 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt @@ -23,6 +23,7 @@ package com.android.developers.androidify.creation import android.net.Uri +import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -36,6 +37,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -125,6 +127,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import com.android.developers.androidify.data.DropBehaviourFactory import com.android.developers.androidify.results.ResultsScreen import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.LimeGreen @@ -183,6 +186,7 @@ fun CreationScreen( ScreenState.EDIT -> { EditScreen( snackbarHostState = snackbarHostState, + dropBehaviourFactory = creationViewModel.dropBehaviourFactory, isExpanded = isMedium, onCameraPressed = onCameraPressed, onBackPressed = onBackPressed, @@ -194,6 +198,7 @@ fun CreationScreen( onPromptGenerationPressed = creationViewModel::onPromptGenerationClicked, onBotColorSelected = creationViewModel::onBotColorChanged, onStartClicked = creationViewModel::startClicked, + onDropCallback = creationViewModel::onImageSelected, ) } @@ -231,6 +236,7 @@ fun CreationScreen( @Composable fun EditScreen( snackbarHostState: SnackbarHostState, + dropBehaviourFactory: DropBehaviourFactory, isExpanded: Boolean, onCameraPressed: () -> Unit, onBackPressed: () -> Unit, @@ -242,6 +248,7 @@ fun EditScreen( onPromptGenerationPressed: () -> Unit, onBotColorSelected: (BotColor) -> Unit, onStartClicked: () -> Unit, + onDropCallback: (Uri) -> Unit = {}, ) { Scaffold( snackbarHost = { @@ -267,8 +274,7 @@ fun EditScreen( expandedCenterButtons = { PromptTypeToolbar( uiState.selectedPromptOption, - modifier = Modifier - .padding(start = 16.dp, end = 16.dp), + modifier = Modifier.padding(start = 16.dp, end = 16.dp), onOptionSelected = onPromptOptionSelected, ) }, @@ -305,6 +311,7 @@ fun EditScreen( ) { MainCreationPane( uiState, + dropBehaviourFactory = dropBehaviourFactory, modifier = Modifier.weight(.6f), onCameraPressed = onCameraPressed, onChooseImageClicked = { @@ -313,6 +320,7 @@ fun EditScreen( onUndoPressed = onUndoPressed, onPromptGenerationPressed = onPromptGenerationPressed, onSelectedPromptOptionChanged = onPromptOptionSelected, + onDropCallback = onDropCallback, ) Box( modifier = Modifier @@ -340,6 +348,7 @@ fun EditScreen( } else { MainCreationPane( uiState, + dropBehaviourFactory = dropBehaviourFactory, modifier = Modifier.weight(1f), onCameraPressed = onCameraPressed, onChooseImageClicked = { @@ -348,6 +357,7 @@ fun EditScreen( onUndoPressed = onUndoPressed, onPromptGenerationPressed = onPromptGenerationPressed, onSelectedPromptOptionChanged = onPromptOptionSelected, + onDropCallback = onDropCallback, ) } @@ -371,8 +381,7 @@ fun EditScreen( }, uiState = uiState, onStartClicked = onStartClicked, - modifier = Modifier - .align(Alignment.CenterHorizontally), + modifier = Modifier.align(Alignment.CenterHorizontally), ) } } @@ -394,18 +403,36 @@ fun EditScreen( @OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun MainCreationPane( uiState: CreationState, + dropBehaviourFactory: DropBehaviourFactory, modifier: Modifier = Modifier, onCameraPressed: () -> Unit, onChooseImageClicked: () -> Unit = {}, onUndoPressed: () -> Unit = {}, onPromptGenerationPressed: () -> Unit, onSelectedPromptOptionChanged: (PromptType) -> Unit, + onDropCallback: (Uri) -> Unit, ) { + val defaultDropAreaBackgroundColor = MaterialTheme.colorScheme.surface + val alternateDropAreaBackgroundColor = MaterialTheme.colorScheme.surfaceVariant + var background by remember { mutableStateOf(defaultDropAreaBackgroundColor) } + + val activity = LocalContext.current as ComponentActivity + val externalAppCallback = remember { + dropBehaviourFactory.createTargetCallback( + activity = activity, + onImageDropped = { uri -> onDropCallback(uri) }, + onDropStarted = { background = alternateDropAreaBackgroundColor }, + onDropEnded = { background = defaultDropAreaBackgroundColor }, + ) + } + + Box( modifier = modifier, ) { val spatialSpec = MaterialTheme.motionScheme.slowSpatialSpec() - val pagerState = rememberPagerState(uiState.selectedPromptOption.ordinal) { PromptType.entries.size } + val pagerState = + rememberPagerState(uiState.selectedPromptOption.ordinal) { PromptType.entries.size } val focusManager = LocalFocusManager.current LaunchedEffect(uiState.selectedPromptOption) { launch { @@ -443,6 +470,23 @@ private fun MainCreationPane( if (imageUri == null) { UploadEmptyState( modifier = Modifier + .background( + color = background, + shape = MaterialTheme.shapes.large, + ) + .dashedRoundedRectBorder( + 2.dp, + MaterialTheme.colorScheme.outline, + cornerRadius = 28.dp, + ) + .dragAndDropTarget( + shouldStartDragAndDrop = { event -> + dropBehaviourFactory.shouldStartDragAndDrop( + event, + ) + }, + target = externalAppCallback, + ) .fillMaxSize() .padding(2.dp), onCameraPressed = onCameraPressed, @@ -602,10 +646,7 @@ fun ImagePreview( with(sharedElementScope) { Box(modifier) { AsyncImage( - ImageRequest.Builder(LocalContext.current) - .data(uri) - .crossfade(false) - .build(), + ImageRequest.Builder(LocalContext.current).data(uri).crossfade(false).build(), placeholder = null, contentDescription = stringResource(CreationR.string.cd_selected_image), modifier = Modifier @@ -829,16 +870,14 @@ fun PromptTypeToolbar( private fun UploadEmptyPreview() { AndroidifyTheme { UploadEmptyState( - { - }, + {}, {}, modifier = Modifier .height(300.dp) .fillMaxWidth(), ) UploadEmptyState( - { - }, + {}, {}, modifier = Modifier .height(400.dp) @@ -855,15 +894,6 @@ private fun UploadEmptyState( ) { Column( modifier = modifier - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(28.dp), - ) - .dashedRoundedRectBorder( - 2.dp, - MaterialTheme.colorScheme.outline, - cornerRadius = 28.dp, - ) .verticalScroll(rememberScrollState()) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt index 7e889ce8..d187c3af 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.developers.androidify.data.DropBehaviourFactory import com.android.developers.androidify.data.ImageDescriptionFailedGenerationException import com.android.developers.androidify.data.ImageGenerationRepository import com.android.developers.androidify.data.ImageValidationError @@ -48,6 +49,7 @@ class CreationViewModel @Inject constructor( val imageGenerationRepository: ImageGenerationRepository, val textGenerationRepository: TextGenerationRepository, val fileProvider: LocalFileProvider, + val dropBehaviourFactory: DropBehaviourFactory, @ApplicationContext val context: Context, ) : ViewModel() { diff --git a/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt b/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt index ffa74cfa..11105ce9 100644 --- a/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt +++ b/feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.SnackbarHostState import com.android.developers.androidify.data.InsufficientInformationException import com.android.developers.testing.data.TestFileProvider import com.android.developers.testing.data.TestInternetConnectivityManager +import com.android.developers.testing.repository.FakeDropImageFactory import com.android.developers.testing.repository.FakeImageGenerationRepository import com.android.developers.testing.repository.TestTextGenerationRepository import com.android.developers.testing.util.MainDispatcherRule @@ -57,6 +58,7 @@ class CreationViewModelTest { imageGenerationRepository, TestTextGenerationRepository(), TestFileProvider(), + FakeDropImageFactory(), context = RuntimeEnvironment.getApplication(), ) }