diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..4bba6b0 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bab9889..07cd712 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 } } @@ -59,4 +67,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) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1936271..1e9787f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,12 +1,14 @@ - + + + + - - + - + @@ -28,6 +30,15 @@ + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/snapandsolve/MainActivity.kt b/app/src/main/java/com/example/snapandsolve/MainActivity.kt index 233be42..c10aa31 100644 --- a/app/src/main/java/com/example/snapandsolve/MainActivity.kt +++ b/app/src/main/java/com/example/snapandsolve/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/com/example/snapandsolve/MainScreen.kt b/app/src/main/java/com/example/snapandsolve/MainScreen.kt index 9e37781..d999c9e 100644 --- a/app/src/main/java/com/example/snapandsolve/MainScreen.kt +++ b/app/src/main/java/com/example/snapandsolve/MainScreen.kt @@ -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( - "Abbrechen", - color = Color.Black - ) } + ) { + 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) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/snapandsolve/MapViewModel.kt b/app/src/main/java/com/example/snapandsolve/MapViewModel.kt index 15aa574..772f85d 100644 --- a/app/src/main/java/com/example/snapandsolve/MapViewModel.kt +++ b/app/src/main/java/com/example/snapandsolve/MapViewModel.kt @@ -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 = 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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/snapandsolve/camera/AlbumAndroidViewModel.kt b/app/src/main/java/com/example/snapandsolve/camera/AlbumAndroidViewModel.kt new file mode 100644 index 0000000..541cb74 --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/camera/AlbumAndroidViewModel.kt @@ -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 = MutableStateFlow( + AlbumViewState() + ) + val viewStateFlow: StateFlow + 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() + 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/snapandsolve/camera/AlbumEvents.kt b/app/src/main/java/com/example/snapandsolve/camera/AlbumEvents.kt new file mode 100644 index 0000000..49c4d6b --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/camera/AlbumEvents.kt @@ -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): Intent() + + data class OnFinishPickingImagesWith(val compositionContext: Context, val imageUrls: List): Intent() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/snapandsolve/camera/AlbumViewModel.kt b/app/src/main/java/com/example/snapandsolve/camera/AlbumViewModel.kt new file mode 100644 index 0000000..10d780a --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/camera/AlbumViewModel.kt @@ -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 = MutableStateFlow( + AlbumViewState() + ) + val viewStateFlow: StateFlow + 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() + 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 +} \ No newline at end of file diff --git a/app/src/main/java/com/example/snapandsolve/camera/AlbumViewState.kt b/app/src/main/java/com/example/snapandsolve/camera/AlbumViewState.kt new file mode 100644 index 0000000..3c7dd70 --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/camera/AlbumViewState.kt @@ -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 = emptyList(), +) \ No newline at end of file diff --git a/app/src/main/java/com/example/snapandsolve/ui/theme/locationHelper.kt b/app/src/main/java/com/example/snapandsolve/ui/theme/locationHelper.kt new file mode 100644 index 0000000..e903832 --- /dev/null +++ b/app/src/main/java/com/example/snapandsolve/ui/theme/locationHelper.kt @@ -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 + ) + ) + } +} \ No newline at end of file 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..4bf3065 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4384c4..b72ef65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -agp = "8.13.0" +agp = "8.13.2" kotlin = "2.0.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" -composeBom = "2025.12.00" -arcgisMapsKotlin = "200.8.0" +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" } @@ -25,17 +25,15 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 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" } -androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } - [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - - diff --git a/settings.gradle.kts b/settings.gradle.kts index a47d2e8..707bd12 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://esri.jfrog.io/artifactory/arcgis") } // <-- NEU } }