From 0c045c8c91709929011a8a9bdd026e48af9a8f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Ahlers?= Date: Thu, 11 Dec 2025 10:43:06 +0100 Subject: [PATCH] =?UTF-8?q?Attachments=20hinzuf=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 12 ++ .../strassenschadenpro2/AlbumViewModel.kt | 111 ++++++++++++ .../strassenschadenpro2/MapViewModel.kt | 31 +++- .../components/album/AlbumEvents.kt | 16 ++ .../components/album/AlbumViewState.kt | 18 ++ .../components/album/PhotoData.kt | 6 + .../strassenschadenpro2/pages/CreatePage.kt | 168 +++++++++++++++++- app/src/main/res/xml/file_paths.xml | 5 + 8 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/de/jadehs/strassenschadenpro2/AlbumViewModel.kt create mode 100644 app/src/main/java/de/jadehs/strassenschadenpro2/components/album/AlbumEvents.kt create mode 100644 app/src/main/java/de/jadehs/strassenschadenpro2/components/album/AlbumViewState.kt create mode 100644 app/src/main/java/de/jadehs/strassenschadenpro2/components/album/PhotoData.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5dce796..c748c9a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,10 +22,22 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/AlbumViewModel.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/AlbumViewModel.kt new file mode 100644 index 0000000..2e25f56 --- /dev/null +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/AlbumViewModel.kt @@ -0,0 +1,111 @@ +package de.jadehs.strassenschadenpro2 + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.jadehs.strassenschadenpro2.components.album.AlbumViewState +import de.jadehs.strassenschadenpro2.components.album.Intent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File +import kotlin.coroutines.CoroutineContext + +class AlbumViewModel(private val coroutineContext: CoroutineContext): ViewModel() { + //region View State + private val _albumViewState: MutableStateFlow = MutableStateFlow(AlbumViewState()) + // exposes the ViewState to the composable view + val viewStateFlow: StateFlow + get() = _albumViewState + // endregion + + // region Intents + // receives user generated events and processes them in the provided coroutine context + fun onReceive(intent: Intent) = viewModelScope.launch(coroutineContext) { + when(intent) { + is Intent.OnPermissionGrantedWith -> { + // Create an empty image file in the app's cache directory + val tempFile = File.createTempFile( + "temp_image_file_", /* prefix */ + ".jpg", /* suffix */ + intent.compositionContext.cacheDir /* cache directory */ + ) + + // Create sandboxed url for this temp file - needed for the camera API + val uri = FileProvider.getUriForFile(intent.compositionContext, + "${BuildConfig.APPLICATION_ID}.provider", /* needs to match the provider information in the manifest */ + tempFile + ) + _albumViewState.value = _albumViewState.value.copy(tempFileUrl = uri) + } + + is Intent.OnPermissionDenied -> { + // maybe log the permission denial event + println("User did not grant permission to use the camera") + } + + is Intent.OnFinishPickingImagesWith -> { + if (intent.imageUrls.isNotEmpty()) { + // Handle picked images + val newImages = mutableListOf() + val imageUriList = _albumViewState.value.imageUris.toMutableList() + for (eachImageUrl in intent.imageUrls) { + val inputStream = intent.compositionContext.contentResolver.openInputStream(eachImageUrl) + val bytes = inputStream?.readBytes() + inputStream?.close() + + if (bytes != null) { + val bitmapOptions = BitmapFactory.Options() + bitmapOptions.inMutable = true + val bitmap: Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bitmapOptions) + newImages.add(bitmap.asImageBitmap()) + imageUriList.add(eachImageUrl) + } else { + // error reading the bytes from the image url + println("The image that was picked could not be read from the device at this url: $eachImageUrl") + } + } + + val currentViewState = _albumViewState.value + val newCopy = currentViewState.copy( + selectedPictures = (currentViewState.selectedPictures + newImages), + tempFileUrl = null, + imageUris = imageUriList + ) + _albumViewState.value = newCopy + } else { + // user did not pick anything + } + } + + is Intent.OnImageSavedWith -> { + val tempImageUrl = _albumViewState.value.tempFileUrl + if (tempImageUrl != null) { + val source = ImageDecoder.createSource(intent.compositionContext.contentResolver, tempImageUrl) + + val currentPictures = _albumViewState.value.selectedPictures.toMutableList() + currentPictures.add(ImageDecoder.decodeBitmap(source).asImageBitmap()) + + val photoUriList = _albumViewState.value.imageUris.toMutableList() + photoUriList.add(tempImageUrl) + + _albumViewState.value = _albumViewState.value.copy( + tempFileUrl = null, + selectedPictures = currentPictures, + imageUris = photoUriList + ) + } + } + + is Intent.OnImageSavingCanceled -> { + _albumViewState.value = _albumViewState.value.copy(tempFileUrl = null) + } + } + } + // endregion +} \ No newline at end of file diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/MapViewModel.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/MapViewModel.kt index 98a8bd9..4b0ff49 100644 --- a/app/src/main/java/de/jadehs/strassenschadenpro2/MapViewModel.kt +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/MapViewModel.kt @@ -20,7 +20,7 @@ import com.arcgismaps.mapping.layers.FeatureLayer import com.arcgismaps.mapping.view.ScreenCoordinate import com.arcgismaps.mapping.view.SingleTapConfirmedEvent import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy -import de.jadehs.strassenschadenpro2.pages.SettingsPage +import de.jadehs.strassenschadenpro2.components.album.PhotoData import kotlinx.coroutines.launch class MapViewModel(application: Application): AndroidViewModel(application) { @@ -119,7 +119,7 @@ class MapViewModel(application: Application): AndroidViewModel(application) { } } - fun createFeatureWithAttributesAt(screenCoordinate: ScreenCoordinate, beschreibung: String, typ: String) { + fun createFeatureWithAttributesAt(screenCoordinate: ScreenCoordinate, beschreibung: String, typ: String,photosFromAlbum: List) { // Create the feature. val feature = serviceFeatureTable?.createFeature()?.apply { // Get the normalized geometry for the tapped location and use it as the feature's geometry. @@ -138,13 +138,28 @@ class MapViewModel(application: Application): AndroidViewModel(application) { serviceFeatureTable?.applyEdits() // Update the feature to get the updated objectid - a temporary ID is used before the feature is added. it.refresh() + + var arcgisFeature = it as? ArcGISFeature + if (arcgisFeature != null && photosFromAlbum.size>0){ + for (photoData in photosFromAlbum){ + arcgisFeature.addAttachment(photoData.name,photoData.contentType,photoData.data) + } + serviceFeatureTable?.updateFeature(arcgisFeature) + serviceFeatureTable?.applyEdits() + } + // Confirm feature addition. snackBarMessage = "Created feature ${it.attributes["objectid"]}" } } } - fun createFeatureWithAttributesAtPoint(point: Point, beschreibung: String, typ: String) { + fun createFeatureWithAttributesAtPoint( + point: Point, + beschreibung: String, + typ: String, + photoDataList: List + ) { // Create the feature. val feature = serviceFeatureTable?.createFeature()?.apply { // Get the normalized geometry for the tapped location and use it as the feature's geometry. @@ -163,6 +178,16 @@ class MapViewModel(application: Application): AndroidViewModel(application) { serviceFeatureTable?.applyEdits() // Update the feature to get the updated objectid - a temporary ID is used before the feature is added. it.refresh() + + var arcgisFeature = it as? ArcGISFeature + if (arcgisFeature != null && photoDataList.size>0){ + for (photoData in photoDataList){ + arcgisFeature.addAttachment(photoData.name,photoData.contentType,photoData.data) + } + serviceFeatureTable?.updateFeature(arcgisFeature) + serviceFeatureTable?.applyEdits() + } + // Confirm feature addition. snackBarMessage = "Created feature ${it.attributes["objectid"]}" } diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/AlbumEvents.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/AlbumEvents.kt new file mode 100644 index 0000000..ac90960 --- /dev/null +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/AlbumEvents.kt @@ -0,0 +1,16 @@ +package de.jadehs.strassenschadenpro2.components.album + +import android.content.Context +import android.net.Uri + + +/** + * User generated events that can be triggered from the UI. + */ +sealed class Intent { + data class OnPermissionGrantedWith(val compositionContext: Context): Intent() + data object OnPermissionDenied: Intent() + data class OnImageSavedWith (val compositionContext: Context): Intent() + data object OnImageSavingCanceled: Intent() + data class OnFinishPickingImagesWith(val compositionContext: Context, val imageUrls: List): Intent() +} \ No newline at end of file diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/AlbumViewState.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/AlbumViewState.kt new file mode 100644 index 0000000..3da482b --- /dev/null +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/AlbumViewState.kt @@ -0,0 +1,18 @@ +package de.jadehs.strassenschadenpro2.components.album + +import android.net.Uri +import androidx.compose.ui.graphics.ImageBitmap + +data class AlbumViewState( + /** + * holds the URL of the temporary file which stores the image taken by the camera. + */ + val tempFileUrl: Uri? = null, + + /** + * holds the list of images taken by camera or selected pictures from the gallery. + */ + val selectedPictures: List = emptyList(), + + val imageUris: List = emptyList() +) diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/PhotoData.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/PhotoData.kt new file mode 100644 index 0000000..f28c000 --- /dev/null +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/components/album/PhotoData.kt @@ -0,0 +1,6 @@ +package de.jadehs.strassenschadenpro2.components.album + +data class PhotoData( + val name: String, + val contentType: String, + val data: ByteArray) diff --git a/app/src/main/java/de/jadehs/strassenschadenpro2/pages/CreatePage.kt b/app/src/main/java/de/jadehs/strassenschadenpro2/pages/CreatePage.kt index 574180a..da56c43 100644 --- a/app/src/main/java/de/jadehs/strassenschadenpro2/pages/CreatePage.kt +++ b/app/src/main/java/de/jadehs/strassenschadenpro2/pages/CreatePage.kt @@ -1,12 +1,26 @@ package de.jadehs.strassenschadenpro2.pages -import android.util.Log +import android.Manifest +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet @@ -14,11 +28,14 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp @@ -28,8 +45,12 @@ import com.arcgismaps.location.LocationDisplayAutoPanMode import com.arcgismaps.toolkit.geoviewcompose.MapView import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay import com.google.android.gms.location.LocationServices +import de.jadehs.strassenschadenpro2.AlbumViewModel import de.jadehs.strassenschadenpro2.MapViewModel import de.jadehs.strassenschadenpro2.components.DropDownMenuBox +import de.jadehs.strassenschadenpro2.components.album.AlbumViewState +import de.jadehs.strassenschadenpro2.components.album.Intent +import de.jadehs.strassenschadenpro2.components.album.PhotoData import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -43,12 +64,16 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) { var enableSaveButton = remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + var albumViewModel = remember { AlbumViewModel(coroutineScope.coroutineContext) } + val locationDisplay = rememberLocationDisplay().apply { setAutoPanMode(LocationDisplayAutoPanMode.Off) } val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() + if (checkPermissions(context)) { // Permissions are already granted. LaunchedEffect(Unit) { @@ -68,6 +93,7 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) { val fuesedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } Column(modifier = modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally) { @@ -92,6 +118,7 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) { enableSaveButton.value = beschreibungTextFieldValue.value.text.isNotEmpty() && currentDamageType.value.isNotEmpty() } ) + AlbumScreen(modifier= Modifier, viewModel = albumViewModel) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly){ Button(enabled = enableSaveButton.value, @@ -103,7 +130,8 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) { var point = Point(location.longitude, location.latitude, SpatialReference.wgs84()) - mapViewModel.createFeatureWithAttributesAtPoint(point,beschreibung,typ) + val photoDataList = getPhotosFromAlbum(context,albumViewModel) + mapViewModel.createFeatureWithAttributesAtPoint(point,beschreibung,typ,photoDataList) } } }){ @@ -133,10 +161,140 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) { val screenCoordinate = singleTapConfirmedEvent.screenCoordinate val beschreibung = beschreibungTextFieldValue.value.text val typ = currentDamageType.value - mapViewModel.createFeatureWithAttributesAt(screenCoordinate,beschreibung,typ) + val photoDataList=getPhotosFromAlbum(context,albumViewModel) + mapViewModel.createFeatureWithAttributesAt( + screenCoordinate, + beschreibung, + typ, + photoDataList + ) }, ) } } -} \ No newline at end of file +} + +@Composable +fun AlbumScreen(modifier: Modifier = Modifier, viewModel: AlbumViewModel) { + // collecting the flow from the view model as a state allows our ViewModel and View + // to be in sync with each other. + val viewState: AlbumViewState by viewModel.viewStateFlow.collectAsState() + + val currentContext = LocalContext.current + + // launches photo picker + val pickImageFromAlbumLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { urls -> + viewModel.onReceive(Intent.OnFinishPickingImagesWith(currentContext, urls)) + } + + // launches camera + val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { isImageSaved -> + if (isImageSaved) { + viewModel.onReceive(Intent.OnImageSavedWith(currentContext)) + } else { + // handle image saving error or cancellation + viewModel.onReceive(Intent.OnImageSavingCanceled) + } + } + + // launches camera permissions + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> + if (permissionGranted) { + viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext)) + } else { + // handle permission denied such as: + viewModel.onReceive(Intent.OnPermissionDenied) + } + } + + // this ensures that the camera is launched only once when the url of the temp file changes + LaunchedEffect(key1 = viewState.tempFileUrl) { + viewState.tempFileUrl?.let { + cameraLauncher.launch(it) + } + } + + // basic view that has 2 buttons and a grid for selected pictures + Column(modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Row { + Button(onClick = { + // get user's permission first to use camera + permissionLauncher.launch(Manifest.permission.CAMERA) + }) { + Text(text = "Take a photo") + } + Spacer(modifier = Modifier.width(16.dp)) + Button(onClick = { + // Image picker does not require special permissions and can be activated right away + val mediaRequest = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + pickImageFromAlbumLauncher.launch(mediaRequest) + }) { + Text(text = "Pick a picture") + } + } + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "Selected Pictures") + LazyVerticalGrid(modifier = Modifier.fillMaxWidth().heightIn(0.dp, 1200.dp), + columns = GridCells.Adaptive(150.dp), + userScrollEnabled = false) { + itemsIndexed(viewState.selectedPictures) { index, picture -> + Image(modifier = Modifier.padding(8.dp), + bitmap = picture, + contentDescription = null, + contentScale = ContentScale.FillWidth + ) + } + } + } +} + +fun getPhotosFromAlbum(context: Context,albumViewModel: AlbumViewModel): List{ + var photoDataList = mutableListOf() + + albumViewModel.viewStateFlow.value.imageUris.forEach { uri -> + context.contentResolver.openInputStream(uri).use { inputStream -> + try { + val name = "image_${System.currentTimeMillis()}.png" + val contentType = "image/png" + val byteArray = inputStream?.readBytes() + + if (byteArray!=null) { + photoDataList.add( + PhotoData( + name, + contentType, + byteArray + ) + ) + } + }catch (e: Exception){ + + } + + } + + } + + return photoDataList +} + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..66374d3 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file