Attachments hinzufügen

This commit is contained in:
2025-12-11 10:43:06 +01:00
parent bd66f298e0
commit 0c045c8c91
8 changed files with 359 additions and 8 deletions

View File

@@ -22,10 +22,22 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest> </manifest>

View File

@@ -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<AlbumViewState> = MutableStateFlow(AlbumViewState())
// exposes the ViewState to the composable view
val viewStateFlow: StateFlow<AlbumViewState>
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<ImageBitmap>()
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
}

View File

@@ -20,7 +20,7 @@ import com.arcgismaps.mapping.layers.FeatureLayer
import com.arcgismaps.mapping.view.ScreenCoordinate import com.arcgismaps.mapping.view.ScreenCoordinate
import com.arcgismaps.mapping.view.SingleTapConfirmedEvent import com.arcgismaps.mapping.view.SingleTapConfirmedEvent
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
import de.jadehs.strassenschadenpro2.pages.SettingsPage import de.jadehs.strassenschadenpro2.components.album.PhotoData
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MapViewModel(application: Application): AndroidViewModel(application) { 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<PhotoData>) {
// Create the feature. // Create the feature.
val feature = serviceFeatureTable?.createFeature()?.apply { val feature = serviceFeatureTable?.createFeature()?.apply {
// Get the normalized geometry for the tapped location and use it as the feature's geometry. // 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() serviceFeatureTable?.applyEdits()
// Update the feature to get the updated objectid - a temporary ID is used before the feature is added. // Update the feature to get the updated objectid - a temporary ID is used before the feature is added.
it.refresh() 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. // Confirm feature addition.
snackBarMessage = "Created feature ${it.attributes["objectid"]}" snackBarMessage = "Created feature ${it.attributes["objectid"]}"
} }
} }
} }
fun createFeatureWithAttributesAtPoint(point: Point, beschreibung: String, typ: String) { fun createFeatureWithAttributesAtPoint(
point: Point,
beschreibung: String,
typ: String,
photoDataList: List<PhotoData>
) {
// Create the feature. // Create the feature.
val feature = serviceFeatureTable?.createFeature()?.apply { val feature = serviceFeatureTable?.createFeature()?.apply {
// Get the normalized geometry for the tapped location and use it as the feature's geometry. // 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() serviceFeatureTable?.applyEdits()
// Update the feature to get the updated objectid - a temporary ID is used before the feature is added. // Update the feature to get the updated objectid - a temporary ID is used before the feature is added.
it.refresh() 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. // Confirm feature addition.
snackBarMessage = "Created feature ${it.attributes["objectid"]}" snackBarMessage = "Created feature ${it.attributes["objectid"]}"
} }

View File

@@ -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<Uri>): Intent()
}

View File

@@ -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<ImageBitmap> = emptyList(),
val imageUris: List<Uri> = emptyList()
)

View File

@@ -0,0 +1,6 @@
package de.jadehs.strassenschadenpro2.components.album
data class PhotoData(
val name: String,
val contentType: String,
val data: ByteArray)

View File

@@ -1,12 +1,26 @@
package de.jadehs.strassenschadenpro2.pages 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.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.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
@@ -14,11 +28,14 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp 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.MapView
import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import de.jadehs.strassenschadenpro2.AlbumViewModel
import de.jadehs.strassenschadenpro2.MapViewModel import de.jadehs.strassenschadenpro2.MapViewModel
import de.jadehs.strassenschadenpro2.components.DropDownMenuBox 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 import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -43,12 +64,16 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) {
var enableSaveButton = remember { mutableStateOf(false) } var enableSaveButton = remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var albumViewModel = remember { AlbumViewModel(coroutineScope.coroutineContext) }
val locationDisplay = rememberLocationDisplay().apply { val locationDisplay = rememberLocationDisplay().apply {
setAutoPanMode(LocationDisplayAutoPanMode.Off) setAutoPanMode(LocationDisplayAutoPanMode.Off)
} }
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
if (checkPermissions(context)) { if (checkPermissions(context)) {
// Permissions are already granted. // Permissions are already granted.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -68,6 +93,7 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) {
val fuesedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } val fuesedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
Column(modifier = modifier.fillMaxSize() Column(modifier = modifier.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally) {
@@ -92,6 +118,7 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) {
enableSaveButton.value = beschreibungTextFieldValue.value.text.isNotEmpty() && currentDamageType.value.isNotEmpty() enableSaveButton.value = beschreibungTextFieldValue.value.text.isNotEmpty() && currentDamageType.value.isNotEmpty()
} }
) )
AlbumScreen(modifier= Modifier, viewModel = albumViewModel)
Row(modifier = Modifier.fillMaxWidth(), Row(modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly){ horizontalArrangement = Arrangement.SpaceEvenly){
Button(enabled = enableSaveButton.value, Button(enabled = enableSaveButton.value,
@@ -103,7 +130,8 @@ fun CreatePage(modifier: Modifier = Modifier, mapViewModel: MapViewModel) {
var point = Point(location.longitude, var point = Point(location.longitude,
location.latitude, location.latitude,
SpatialReference.wgs84()) 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 screenCoordinate = singleTapConfirmedEvent.screenCoordinate
val beschreibung = beschreibungTextFieldValue.value.text val beschreibung = beschreibungTextFieldValue.value.text
val typ = currentDamageType.value val typ = currentDamageType.value
mapViewModel.createFeatureWithAttributesAt(screenCoordinate,beschreibung,typ) val photoDataList=getPhotosFromAlbum(context,albumViewModel)
mapViewModel.createFeatureWithAttributesAt(
screenCoordinate,
beschreibung,
typ,
photoDataList
)
}, },
) )
} }
} }
} }
@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<PhotoData>{
var photoDataList = mutableListOf<PhotoData>()
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
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- creates a reference to the cache folder that the system maintains -->
<cache-path name="temporary_camera_images" path="/" />
</paths>