Compare commits

...

6 Commits

Author SHA1 Message Date
e7f0ef03ad - Standortbestimmungfunktionalität hinzugefügt 2025-12-20 11:22:12 +01:00
2f781abcc2 - Nur ArcGIS Karte implementiert
- in toml arcgis libraries hinzugefügt
- mindSdk von 27 auf 28 gesetzt
2025-12-19 20:08:56 +01:00
daeed139be Kamera funktionalität
-AlbumAndroidViewModel
-AlbumEvents
-AlbumViewModel
-AlbumViewState
-file_patchs
SelectPictureScree.kt wurde gelöscht. Die Funktionanlität  AlbumScreen() wurde in ReportOverlay neu aufgebaut.
2025-12-17 22:57:08 +01:00
1070adf13f Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/src/main/AndroidManifest.xml
#	app/src/main/java/com/example/snapandsolve/MainScreen.kt
2025-12-17 22:31:23 +01:00
c644361ab8 Kamera funktionalität
-AlbumAndroidViewModel
-AlbumEvents
-AlbumViewModel
-AlbumViewState
-file_patchs
SelectPictureScree.kt wurde gelöscht. Die Funktionanlität  AlbumScreen() wurde in ReportOverlay neu aufgebaut.
2025-12-17 22:26:26 +01:00
9048d31413 Kamera funktionalität
-AlbumAndroidViewModel
-AlbumEvents
-AlbumViewModel
-AlbumViewState
2025-12-17 19:28:59 +01:00
16 changed files with 821 additions and 35 deletions

123
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-12-19T16:43:39.005516700Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=N0AA003656K80600629" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View File

@@ -12,12 +12,19 @@ android {
defaultConfig {
applicationId = "com.example.snapandsolve"
minSdk = 27
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
val properties = org.jetbrains.kotlin.konan.properties.Properties()
val propertiesFile = rootProject.file("local.properties")
if (propertiesFile.exists()){
propertiesFile.inputStream().use { properties.load(it) }
}
buildConfigField("String", "ARCGIS_TOKEN","\"${properties.getProperty("arcgis.token","")}\"")
}
buildTypes {
@@ -38,6 +45,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}
@@ -60,4 +68,11 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
// ArcGIS Maps for Kotlin - SDK dependency
implementation(libs.arcgis.maps.kotlin)
// Toolkit dependencies
implementation(platform(libs.arcgis.maps.kotlin.toolkit.bom))
implementation(libs.arcgis.maps.kotlin.toolkit.geoview.compose)
implementation(libs.arcgis.maps.kotlin.toolkit.authentication)
}

View File

@@ -1,12 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Kamera Zugriffsberechtigung -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- ArcGIS Pro benötigt-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Für Standortbestimmung -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="true"
@@ -18,7 +20,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.SnapAndSolve">
<activity
android:name=".MainActivity"
android:name="com.example.snapandsolve.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.SnapAndSolve">
@@ -28,6 +30,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</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>
</manifest>

View File

@@ -7,15 +7,27 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.arcgismaps.ApiKey
import com.arcgismaps.ArcGISEnvironment
import com.example.snapandsolve.camera.AlbumViewModel
import com.example.snapandsolve.ui.theme.SnapAndSolveTheme
import kotlinx.coroutines.Dispatchers
class MainActivity : ComponentActivity() {
private lateinit var viewModel: AlbumViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = AlbumViewModel(coroutineContext = Dispatchers.Default)
/*
Wird gebraucht um die Karte in ArcGIS anzuzeigen. Die Prüfung ob man Zugang hat oder nicht
wurde gelöscht.
*/
ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ARCGIS_TOKEN)
enableEdgeToEdge()
setContent {

View File

@@ -1,6 +1,11 @@
package com.example.snapandsolve
import android.Manifest
import android.app.Application
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.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -13,35 +18,34 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CornerSize
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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeFloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
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
@@ -50,10 +54,21 @@ import androidx.compose.runtime.setValue
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.unit.dp
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.BasemapStyle
import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.toolkit.geoviewcompose.MapView
import com.example.snapandsolve.camera.AlbumViewModel
import com.example.snapandsolve.camera.AlbumViewState
import com.example.snapandsolve.camera.Intent
import com.example.snapandsolve.ui.theme.AppColor
import com.example.snapandsolve.ui.theme.ButtonColor
import com.example.snapandsolve.ui.theme.WidgetColor
import com.example.snapandsolve.ui.theme.setupLocationDisplay
import kotlinx.coroutines.Dispatchers
@Composable
@@ -69,7 +84,6 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
BottomAppBar(
modifier = Modifier.height(120.dp),
containerColor = AppColor,
// contentColor = AppColor
) {
Row(
modifier = Modifier
@@ -81,12 +95,11 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
onClick = {
/*TODO*/
},
modifier = Modifier.padding(bottom = 8.dp) // Abstand "in" der Bar
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
Icons.Default.Menu,
contentDescription = "Menu",
)
}
Spacer(Modifier.weight(1f))
@@ -105,8 +118,7 @@ fun MainScreen(modifier: Modifier = Modifier, application: Application) {
}
},
floatingActionButtonPosition = FabPosition.Center,
) {
innerPadding ->
) { innerPadding ->
ContentScreen(
modifier = Modifier.padding(innerPadding),
application,
@@ -123,13 +135,30 @@ fun ContentScreen(
onDismissReport: () -> Unit
) {
val mapViewModel = remember { MapViewModel(application) }
Box(modifier = modifier.fillMaxSize()) {
val albumViewModel = remember { AlbumViewModel(Dispatchers.Default) }
// 2) Overlay
// ArcGIS Map erstellen
val map = remember {
createMap() //Funktion zur Erstellung der Map
}
//Standortbestimmung aus locationHelper.kt
val locationDisplay = setupLocationDisplay()
Box(modifier = modifier.fillMaxSize()) {
// HINTERGRUND: Die Map
MapView(
modifier = Modifier.fillMaxSize(),
arcGISMap = map,
locationDisplay = locationDisplay
)
// VORDERGRUND: Das Overlay (wenn showReport = true)
if (showReport) {
ReportOverlay(
onCancel = onDismissReport,
onAdd = { /* später */ }
onAdd = { /* später */ },
viewModel = albumViewModel
)
}
}
@@ -148,15 +177,71 @@ fun AppTopBar(
containerColor = AppColor,
titleContentColor = Color.White
)
)
}
@Composable
fun ReportOverlay(
onCancel: () -> Unit,
onAdd: () -> Unit
onAdd: () -> Unit,
viewModel: AlbumViewModel
) {
val viewState: AlbumViewState by viewModel.viewStateFlow.collectAsState()
val currentContext = LocalContext.current
// Launcher für Bildauswahl aus Galerie
val pickImageFromAlbumLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(20)
) { urls ->
viewModel.onReceive(Intent.OnFinishPickingImagesWith(currentContext, urls))
}
// Launcher für Kamera
val cameraLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture()
) { isImageSaved ->
if (isImageSaved) {
viewModel.onReceive(Intent.OnImageSavedWith(currentContext))
} else {
viewModel.onReceive(Intent.OnImageSavingCanceled)
}
}
// Launcher für Kamera-Berechtigung
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { permissionGranted ->
if (permissionGranted) {
viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext))
} else {
viewModel.onReceive(Intent.OnPermissionDenied)
}
}
// Funktion zum Starten der Kamera (prüft Berechtigung)
fun startCamera() {
println("DEBUG: startCamera() aufgerufen")
val hasPermission = currentContext.checkSelfPermission(Manifest.permission.CAMERA) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
println("DEBUG: Hat Berechtigung? $hasPermission")
if (hasPermission) {
println("DEBUG: Erstelle tempFileUrl")
// Berechtigung bereits erteilt -> direkt tempFileUrl erstellen
viewModel.onReceive(Intent.OnPermissionGrantedWith(currentContext))
} else {
println("DEBUG: Frage Berechtigung an")
// Berechtigung anfragen
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
// Kamera starten, wenn tempFileUrl gesetzt ist
LaunchedEffect(key1 = viewState.tempFileUrl) {
viewState.tempFileUrl?.let {
cameraLauncher.launch(it)
}
}
// leichter Dim-Hintergrund
Box(
modifier = Modifier
@@ -179,14 +264,41 @@ fun ReportOverlay(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
.padding(20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Schadensbeschreibung:",
Text(
"Schadensbeschreibung:",
color = Color.Black
)
// Platzhalter fürs Textfeld / Icons etc.
// Kamera-Buttons (über der weißen Box)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = {
startCamera()
},
modifier = Modifier.weight(1f)
) {
Text(text = "Foto aufnehmen")
}
Button(
onClick = {
pickImageFromAlbumLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
modifier = Modifier.weight(1f)
) {
Text(text = "Aus Galerie")
}
}
// Die weiße Textfeld Box
Box(
modifier = Modifier
.fillMaxWidth()
@@ -194,6 +306,30 @@ fun ReportOverlay(
.background(Color.White, RoundedCornerShape(12.dp))
)
// Grid für ausgewählte Bilder (außerhalb der Box, aber in der Card)
if (viewState.selectedPictures.isNotEmpty()) {
Text(
text = "Ausgewählte Bilder (${viewState.selectedPictures.size})",
color = Color.Black
)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
userScrollEnabled = false,
modifier = Modifier
.fillMaxWidth()
.heightIn(0.dp, 200.dp)
) {
itemsIndexed(viewState.selectedPictures) { index, picture ->
Image(
modifier = Modifier.padding(4.dp),
bitmap = picture,
contentDescription = "Bild ${index + 1}",
contentScale = ContentScale.Crop
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
@@ -206,16 +342,32 @@ fun ReportOverlay(
disabledContainerColor = Color.White,
disabledContentColor = Color.White
)
) { Text(
) {
Text(
"Abbrechen",
color = Color.Black
) }
)
}
Button(onClick = onAdd) { Text("Hinzufügen") }
}
}
}
}
}
fun createMap(): ArcGISMap {
return ArcGISMap(BasemapStyle.ArcGISTopographic).apply {
initialViewpoint = Viewpoint(
53.14,
8.20,
20000.0)
}
}

View File

@@ -1,8 +1,64 @@
package com.example.snapandsolve
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.arcgismaps.data.ArcGISFeature
import com.arcgismaps.data.CodedValueDomain
import com.arcgismaps.data.ServiceFeatureTable
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.BasemapStyle
import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.mapping.layers.FeatureLayer
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
import kotlinx.coroutines.launch
class MapViewModel(application: Application ): AndroidViewModel(application) {
val map: ArcGISMap = ArcGISMap(BasemapStyle.OpenOsmStyle).apply {
initialViewpoint = Viewpoint(53.14, 8.20, 20000.0)
}
/*
ALLES UNTER DIESEM KOMMENTAR WIRD NICHT GENUTZT. Aber EVENTUELL nötig zum einbinden von
Layer und Features.
*/
// Hold a reference to the selected feature.
var selectedFeature: ArcGISFeature? by mutableStateOf(null)
val mapViewProxy = MapViewProxy()
//var currentFeatureOperation by mutableStateOf(FeatureOperationType.CREATE)
lateinit var featureLayer: FeatureLayer
// Create a snackbar message to display the result of feature operations.
var snackBarMessage: String by mutableStateOf("")
lateinit var serviceFeatureTable: ServiceFeatureTable
var currentDamageType by mutableStateOf("")
// The list of damage types to update the feature attribute.
var damageTypeList: List<String> = mutableListOf()
init {
viewModelScope.launch {
serviceFeatureTable = ServiceFeatureTable("https://services9.arcgis.com/UVxdrlZq3S3gqt7w/ArcGIS/rest/services/StrassenSchaeden/FeatureServer/0")
serviceFeatureTable.load().onSuccess {
// Get the field from the feature table that will be updated.
val typeDamageField = serviceFeatureTable.fields.first { it.name == "Typ" }
// Get the coded value domain for the field.
val attributeDomain = typeDamageField.domain as CodedValueDomain
// Add the damage types to the list.
attributeDomain.codedValues.forEach {
damageTypeList += it.name
}
}
featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable)
map.operationalLayers.add(featureLayer)
}
}
}

View File

@@ -0,0 +1,119 @@
package com.example.snapandsolve.camera
import android.app.Application
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.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.snapandsolve.BuildConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.io.File
import kotlin.coroutines.CoroutineContext
/**
* This variant inherits from [AndroidViewModel] and has access to the application context
*/
class AlbumAndroidViewModel(private val appContext: Application,
private val coroutineContext: CoroutineContext
): AndroidViewModel(appContext) {
//region View State
private val _albumViewState: MutableStateFlow<AlbumViewState> = MutableStateFlow(
AlbumViewState()
)
val viewStateFlow: StateFlow<AlbumViewState>
get() = _albumViewState
//endregion
fun onEvent(intent: Intent) = viewModelScope.launch(coroutineContext) {
when(intent) {
is Intent.OnPermissionGranted -> {
// Create an empty image file in the app's cache directory
val file = File.createTempFile(
"temp_image_file_", /* prefix */
".jpg", /* suffix */
appContext.cacheDir /* cache directory */
)
// Create sandboxed url for this temp file - needed for the camera API
val uri = FileProvider.getUriForFile(appContext,
"${BuildConfig.APPLICATION_ID}.provider",
file
)
_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.OnFinishPickingImages -> {
if (intent.imageUrls.isNotEmpty()) {
// Handle picked images
val newImages = mutableListOf<ImageBitmap>()
for (eachImageUrl in intent.imageUrls) {
val inputStream = appContext.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)
val imageBitmap = bitmap.asImageBitmap()
newImages.add(imageBitmap)
} 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
)
_albumViewState.value = newCopy
} else {
// user did not pick anything
}
}
is Intent.OnImageSaved -> {
val tempImageUrl = _albumViewState.value.tempFileUrl
if (tempImageUrl != null) {
val source: ImageDecoder.Source = ImageDecoder.createSource(appContext.contentResolver, tempImageUrl)
val currentPictures = _albumViewState.value.selectedPictures.toMutableList()
currentPictures.add(ImageDecoder.decodeBitmap(source).asImageBitmap())
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null,
selectedPictures = currentPictures)
}
}
is Intent.OnImageSavingCanceled -> {
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null)
}
is Intent.OnFinishPickingImagesWith -> {
// unnecessary in this viewmodel variant
}
is Intent.OnPermissionGrantedWith -> {
// unnecessary in this viewmodel variant
}
is Intent.OnImageSavedWith -> {
// unnecessary in this viewmodel variant
}
}
}
}

View File

@@ -0,0 +1,28 @@
package com.example.snapandsolve.camera
import android.content.Context
import android.net.Uri
/*
Das sind die Funktionen beim Drücken der Knöpfe. Übersichtlicher wäre es sie direkt mit den
Knöpfen in der ViewModel zu platzieren. AlbumEvents als name ist vielleicht unglücklich gewählt.
*/
/**
* User generated events that can be triggered from the UI.
*/
sealed class Intent {
data object OnPermissionGranted: Intent()
data class OnPermissionGrantedWith(val compositionContext: Context): Intent()
data object OnPermissionDenied: Intent()
data object OnImageSaved: Intent()
data class OnImageSavedWith (val compositionContext: Context): Intent()
data object OnImageSavingCanceled: Intent()
data class OnFinishPickingImages(val imageUrls: List<Uri>): Intent()
data class OnFinishPickingImagesWith(val compositionContext: Context, val imageUrls: List<Uri>): Intent()
}

View File

@@ -0,0 +1,119 @@
package com.example.snapandsolve.camera
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 com.example.snapandsolve.BuildConfig
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()
)
val viewStateFlow: StateFlow<AlbumViewState>
get() = _albumViewState
//endregion
// region Intents
fun onReceive(intent: Intent) = viewModelScope.launch(coroutineContext) {
when(intent) {
is Intent.OnPermissionGrantedWith -> {
println("DEBUG: OnPermissionGrantedWith empfangen")
val tempFile = File.createTempFile(
"temp_image_file_",
".jpg",
intent.compositionContext.cacheDir
)
println("DEBUG: TempFile erstellt: ${tempFile.absolutePath}")
val uri = FileProvider.getUriForFile(intent.compositionContext,
"${BuildConfig.APPLICATION_ID}.provider",
tempFile
)
println("DEBUG: URI erstellt: $uri")
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = uri)
println("DEBUG: tempFileUrl gesetzt in ViewState")
}
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>()
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())
} 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
)
_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())
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null,
selectedPictures = currentPictures)
}
}
is Intent.OnImageSavingCanceled -> {
_albumViewState.value = _albumViewState.value.copy(tempFileUrl = null)
}
is Intent.OnPermissionGranted -> {
// unnecessary in this viewmodel variant
}
is Intent.OnFinishPickingImages -> {
// unnecessary in this viewmodel variant
}
is Intent.OnImageSaved -> {
// unnecessary in this viewmodel variant
}
}
}
// endregion
}

View File

@@ -0,0 +1,19 @@
package com.example.snapandsolve.camera
import android.net.Uri
import androidx.compose.ui.graphics.ImageBitmap
/**
* Holds state data for the MainScreen composable.
*/
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(),
)

View File

@@ -0,0 +1,109 @@
package com.example.snapandsolve.ui.theme
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.content.ContextCompat
import com.arcgismaps.location.LocationDisplayAutoPanMode
import com.arcgismaps.mapping.view.LocationDisplay
import com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplay
import kotlinx.coroutines.launch
/**
* Helper-Klasse für Standort-Funktionalität
*/
class LocationHelper(private val context: Context) {
/**
* Prüft, ob Standort-Berechtigungen erteilt wurden
*/
fun hasLocationPermissions(): Boolean {
val coarseLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val fineLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
return coarseLocation && fineLocation
}
}
/**
* Composable zum Einrichten des Location Display
*
* @param autoPanMode Wie die Karte dem Standort folgen soll (default: Recenter)
* @return LocationDisplay-Objekt, das an MapView übergeben werden kann
*/
@Composable
fun setupLocationDisplay(
autoPanMode: LocationDisplayAutoPanMode = LocationDisplayAutoPanMode.Recenter
): LocationDisplay {
val context = androidx.compose.ui.platform.LocalContext.current
val coroutineScope = rememberCoroutineScope()
val locationHelper = LocationHelper(context)
val locationDisplay = rememberLocationDisplay().apply {
setAutoPanMode(autoPanMode)
}
if (locationHelper.hasLocationPermissions()) {
LaunchedEffect(Unit) {
locationDisplay.dataSource.start()
}
} else {
RequestLocationPermissions(
context = context,
onPermissionsGranted = {
coroutineScope.launch {
locationDisplay.dataSource.start()
}
}
)
}
return locationDisplay
}
/**
* Composable zum Anfordern von Standort-Berechtigungen
*/
@Composable
private fun RequestLocationPermissions(
context: Context,
onPermissionsGranted: () -> Unit
) {
val activityResultLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
if (permissions.all { it.value }) {
onPermissionsGranted()
} else {
Toast.makeText(
context,
"Standort-Berechtigung wurde verweigert",
Toast.LENGTH_LONG
).show()
}
}
LaunchedEffect(Unit) {
activityResultLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
)
)
}
}

View File

@@ -0,0 +1,4 @@
<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>

View File

@@ -9,6 +9,7 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.1"
composeBom = "2024.09.00"
material3 = "1.4.0"
arcgisMapsKotlin = "200.8.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -26,6 +27,10 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
arcgis-maps-kotlin = { group = "com.esri", name = "arcgis-maps-kotlin", version.ref = "arcgisMapsKotlin" }
arcgis-maps-kotlin-toolkit-bom = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-bom", version.ref = "arcgisMapsKotlin" }
arcgis-maps-kotlin-toolkit-geoview-compose = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-geoview-compose" }
arcgis-maps-kotlin-toolkit-authentication = { group = "com.esri", name = "arcgis-maps-kotlin-toolkit-authentication" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://esri.jfrog.io/artifactory/arcgis") } // <-- NEU
}
}