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