feat: KI-Kameraerkennung via OpenAI Vision (Issue #48)
- CameraCapture-Screen: Foto aufnehmen, analysieren, Artikel auswählen - OpenAiVisionService: gpt-4o Vision API via Ktor (buildJsonObject) - CameraViewModel: @ApplicationContext, Bitmap-Laden, Nav-Event via UiState - ItemFormViewModel: prefillJson-Route-Parameter, _pendingCategoryName-Matching - Settings: OpenAI API-Key (OPENAI_API_KEY) speichern/laden - Screen.CameraCapture + Screen.ItemForm(prefillJson) in NavGraph - ItemListScreen: PhotoCamera-Icon in TopAppBar - AndroidManifest: TakePicture braucht keine CAMERA-Permission (Intent-basiert) - 8 neue CameraViewModel-Tests, 1 neuer ItemFormViewModel-Test (226 Tests grün)
This commit is contained in:
parent
f4b5197b06
commit
dd571f46fc
18 changed files with 896 additions and 3 deletions
|
|
@ -0,0 +1,98 @@
|
|||
package de.krisenvorrat.app.data.remote
|
||||
|
||||
import de.krisenvorrat.app.domain.model.ItemFormPrefill
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.bearerAuth
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import javax.inject.Inject
|
||||
|
||||
internal interface OpenAiVisionService {
|
||||
suspend fun analyzeImage(base64Image: String, apiKey: String): List<ItemFormPrefill>
|
||||
}
|
||||
|
||||
internal class OpenAiVisionServiceImpl @Inject constructor(
|
||||
private val httpClient: HttpClient
|
||||
) : OpenAiVisionService {
|
||||
|
||||
override suspend fun analyzeImage(base64Image: String, apiKey: String): List<ItemFormPrefill> {
|
||||
return try {
|
||||
val requestBody = buildJsonObject {
|
||||
put("model", "gpt-4o")
|
||||
put("response_format", buildJsonObject { put("type", "json_object") })
|
||||
putJsonArray("messages") {
|
||||
addJsonObject {
|
||||
put("role", "user")
|
||||
putJsonArray("content") {
|
||||
addJsonObject {
|
||||
put("type", "text")
|
||||
put("text", RECOGNITION_PROMPT)
|
||||
}
|
||||
addJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "data:image/jpeg;base64,$base64Image")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val response: JsonObject = httpClient.post(OPENAI_API_URL) {
|
||||
bearerAuth(apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(requestBody)
|
||||
}.body()
|
||||
|
||||
val content = response["choices"]
|
||||
?.jsonArray
|
||||
?.firstOrNull()
|
||||
?.jsonObject
|
||||
?.get("message")
|
||||
?.jsonObject
|
||||
?.get("content")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?: return emptyList()
|
||||
|
||||
val parsed = json.decodeFromString<JsonObject>(content)
|
||||
val itemsArray = parsed["items"]?.jsonArray ?: return emptyList()
|
||||
|
||||
itemsArray.mapNotNull { element ->
|
||||
try {
|
||||
json.decodeFromJsonElement<ItemFormPrefill>(element)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
const val OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"
|
||||
const val RECOGNITION_PROMPT =
|
||||
"Erkenne alle Nahrungsmittel und Produkte in diesem Foto. " +
|
||||
"Antworte ausschließlich im JSON-Format: " +
|
||||
"{\"items\": [{\"name\": \"Produktname\", \"suggestedCategoryName\": \"Kategoriename\", " +
|
||||
"\"unit\": \"Einheit\", \"kcalPerKg\": <Int oder null>, \"notes\": \"Zusatzinfos\"}]}. " +
|
||||
"Verwende deutsche Produktnamen."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package de.krisenvorrat.app.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
internal annotation class IoDispatcher
|
||||
|
|
@ -5,12 +5,16 @@ import dagger.Module
|
|||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.krisenvorrat.app.data.remote.OpenAiVisionService
|
||||
import de.krisenvorrat.app.data.remote.OpenAiVisionServiceImpl
|
||||
import de.krisenvorrat.app.data.sync.SyncServiceImpl
|
||||
import de.krisenvorrat.app.domain.repository.SyncService
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -23,6 +27,9 @@ internal abstract class NetworkModule {
|
|||
@Singleton
|
||||
abstract fun bindSyncService(impl: SyncServiceImpl): SyncService
|
||||
|
||||
@Binds
|
||||
abstract fun bindOpenAiVisionService(impl: OpenAiVisionServiceImpl): OpenAiVisionService
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
|
|
@ -43,6 +50,10 @@ internal abstract class NetworkModule {
|
|||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 10L
|
||||
private const val READ_TIMEOUT_SECONDS = 30L
|
||||
private const val WRITE_TIMEOUT_SECONDS = 30L
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package de.krisenvorrat.app.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class ItemFormPrefill(
|
||||
val name: String = "",
|
||||
val suggestedCategoryName: String = "",
|
||||
val unit: String = "",
|
||||
val kcalPerKg: Int? = null,
|
||||
val notes: String = ""
|
||||
)
|
||||
|
|
@ -7,4 +7,5 @@ internal object SettingsKeys {
|
|||
const val SERVER_URL = "server_url"
|
||||
const val API_KEY = "api_key"
|
||||
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
||||
const val OPENAI_API_KEY = "openai_api_key"
|
||||
}
|
||||
|
|
|
|||
253
app/src/main/java/de/krisenvorrat/app/ui/camera/CameraScreen.kt
Normal file
253
app/src/main/java/de/krisenvorrat/app/ui/camera/CameraScreen.kt
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
package de.krisenvorrat.app.ui.camera
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.krisenvorrat.app.domain.model.ItemFormPrefill
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun CameraScreen(
|
||||
onNavigateToItemForm: (prefillJson: String) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: CameraViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var currentPhotoUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val cameraLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture()
|
||||
) { success ->
|
||||
if (success) {
|
||||
currentPhotoUri?.let { viewModel.onPhotoCaptured(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val takePicture: () -> Unit = {
|
||||
val file = File(context.cacheDir, "camera/photo_${System.currentTimeMillis()}.jpg")
|
||||
file.parentFile?.mkdirs()
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
currentPhotoUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.errorMessage) {
|
||||
val error = uiState.errorMessage ?: return@LaunchedEffect
|
||||
snackbarHostState.showSnackbar(error)
|
||||
viewModel.clearError()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.navigateToPrefillJson) {
|
||||
val json = uiState.navigateToPrefillJson ?: return@LaunchedEffect
|
||||
onNavigateToItemForm(json)
|
||||
viewModel.onNavigationHandled()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Foto erfassen") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Zurück"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
if (!uiState.hasApiKey) {
|
||||
Text(
|
||||
text = "OpenAI API-Key in den Einstellungen hinterlegen",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (uiState.capturedImageUri == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Button(onClick = takePicture) {
|
||||
Text("Foto aufnehmen")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uiState.capturedBitmap?.let { bmp ->
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "Aufgenommenes Foto",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = takePicture,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Erneut aufnehmen")
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.analyzePhoto() },
|
||||
enabled = !uiState.isAnalyzing,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Analysieren")
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isAnalyzing) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.recognizedItems.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Erkannte Artikel",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
TextButton(onClick = viewModel::selectAll) {
|
||||
Text("Alle auswählen")
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
itemsIndexed(uiState.recognizedItems) { index, item ->
|
||||
RecognizedItemRow(
|
||||
item = item,
|
||||
isSelected = uiState.selectedItems.contains(index),
|
||||
onToggle = { viewModel.toggleItemSelection(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.selectedItems.size > 1) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Bitte füge die Artikel nacheinander hinzu.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { viewModel.onAddFirstSelectedItem() },
|
||||
enabled = uiState.selectedItems.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Ausgewählte hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecognizedItemRow(
|
||||
item: ItemFormPrefill,
|
||||
isSelected: Boolean,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onToggle() }
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (item.notes.isNotBlank()) {
|
||||
Text(
|
||||
text = item.notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package de.krisenvorrat.app.ui.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import de.krisenvorrat.app.domain.model.ItemFormPrefill
|
||||
|
||||
internal data class CameraUiState(
|
||||
val capturedImageUri: Uri? = null,
|
||||
val capturedBitmap: Bitmap? = null,
|
||||
val isAnalyzing: Boolean = false,
|
||||
val recognizedItems: List<ItemFormPrefill> = emptyList(),
|
||||
val selectedItems: Set<Int> = emptySet(),
|
||||
val errorMessage: String? = null,
|
||||
val hasApiKey: Boolean = false,
|
||||
val navigateToPrefillJson: String? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package de.krisenvorrat.app.ui.camera
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.krisenvorrat.app.data.remote.OpenAiVisionService
|
||||
import de.krisenvorrat.app.di.IoDispatcher
|
||||
import de.krisenvorrat.app.domain.model.ItemFormPrefill
|
||||
import de.krisenvorrat.app.domain.model.SettingsKeys
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.Base64
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class CameraViewModel @Inject constructor(
|
||||
private val openAiVisionService: OpenAiVisionService,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(CameraUiState())
|
||||
val uiState: StateFlow<CameraUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
checkApiKey()
|
||||
}
|
||||
|
||||
private fun checkApiKey() {
|
||||
viewModelScope.launch {
|
||||
val apiKey = settingsRepository.getValue(SettingsKeys.OPENAI_API_KEY) ?: ""
|
||||
_uiState.update { it.copy(hasApiKey = apiKey.isNotBlank()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onPhotoCaptured(uri: Uri) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
capturedImageUri = uri,
|
||||
capturedBitmap = null,
|
||||
recognizedItems = emptyList(),
|
||||
selectedItems = emptySet(),
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val bitmap: Bitmap? = withContext(ioDispatcher) {
|
||||
try {
|
||||
appContext.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(capturedBitmap = bitmap) }
|
||||
}
|
||||
}
|
||||
|
||||
fun analyzePhoto() {
|
||||
val uri = _uiState.value.capturedImageUri
|
||||
if (uri == null) {
|
||||
_uiState.update { it.copy(errorMessage = "Kein Foto aufgenommen") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isAnalyzing = true, errorMessage = null) }
|
||||
try {
|
||||
val apiKey = settingsRepository.getValue(SettingsKeys.OPENAI_API_KEY) ?: ""
|
||||
if (apiKey.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAnalyzing = false,
|
||||
errorMessage = "Bitte OpenAI API-Key in den Einstellungen hinterlegen"
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val bytes = withContext(ioDispatcher) {
|
||||
try {
|
||||
appContext.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes == null) {
|
||||
_uiState.update {
|
||||
it.copy(isAnalyzing = false, errorMessage = "Foto konnte nicht gelesen werden")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val base64 = Base64.getEncoder().encodeToString(bytes)
|
||||
val items = openAiVisionService.analyzeImage(base64, apiKey)
|
||||
|
||||
if (items.isEmpty()) {
|
||||
_uiState.update {
|
||||
it.copy(isAnalyzing = false, errorMessage = "Keine Artikel erkannt")
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(isAnalyzing = false, recognizedItems = items, selectedItems = emptySet())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAnalyzing = false,
|
||||
errorMessage = "Analyse fehlgeschlagen. Bitte prüfe die Netzwerkverbindung und den API-Key."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleItemSelection(index: Int) {
|
||||
_uiState.update { state ->
|
||||
val updated = if (state.selectedItems.contains(index)) {
|
||||
state.selectedItems - index
|
||||
} else {
|
||||
state.selectedItems + index
|
||||
}
|
||||
state.copy(selectedItems = updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
_uiState.update { state ->
|
||||
state.copy(selectedItems = state.recognizedItems.indices.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
fun onAddFirstSelectedItem() {
|
||||
val state = _uiState.value
|
||||
val firstIndex = state.selectedItems.minOrNull() ?: return
|
||||
val item = state.recognizedItems.getOrNull(firstIndex) ?: return
|
||||
val json = Json.encodeToString(ItemFormPrefill.serializer(), item)
|
||||
_uiState.update { it.copy(navigateToPrefillJson = json) }
|
||||
}
|
||||
|
||||
fun onNavigationHandled() {
|
||||
_uiState.update { it.copy(navigateToPrefillJson = null) }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
}
|
||||
|
|
@ -10,11 +10,13 @@ import de.krisenvorrat.app.data.db.entity.LocationEntity
|
|||
import de.krisenvorrat.app.domain.repository.CategoryRepository
|
||||
import de.krisenvorrat.app.domain.repository.ItemRepository
|
||||
import de.krisenvorrat.app.domain.repository.LocationRepository
|
||||
import de.krisenvorrat.app.domain.model.ItemFormPrefill
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
|
@ -51,11 +53,16 @@ internal class ItemFormViewModel @Inject constructor(
|
|||
val uiState: StateFlow<ItemFormUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val editItemId: String? = savedStateHandle.get<String>("itemId")
|
||||
private val prefillJson: String? = savedStateHandle.get<String>("prefillJson")
|
||||
private var _pendingCategoryName: String? = null
|
||||
private val jsonParser = Json { ignoreUnknownKeys = true }
|
||||
|
||||
init {
|
||||
loadDropdownData()
|
||||
if (editItemId != null) {
|
||||
loadItem(editItemId)
|
||||
} else if (prefillJson != null) {
|
||||
applyPrefill(prefillJson)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +70,14 @@ internal class ItemFormViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
categoryRepository.getAll().collect { categories ->
|
||||
_uiState.update { it.copy(categories = categories) }
|
||||
val pending = _pendingCategoryName
|
||||
if (pending != null) {
|
||||
val match = categories.find { it.name.equals(pending, ignoreCase = true) }
|
||||
if (match != null) {
|
||||
_uiState.update { it.copy(categoryId = match.id) }
|
||||
_pendingCategoryName = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
|
|
@ -93,6 +108,25 @@ internal class ItemFormViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun applyPrefill(json: String) {
|
||||
try {
|
||||
val prefill = jsonParser.decodeFromString<ItemFormPrefill>(json)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
name = prefill.name,
|
||||
unit = prefill.unit,
|
||||
kcalPerKg = prefill.kcalPerKg?.toString() ?: "",
|
||||
notes = prefill.notes
|
||||
)
|
||||
}
|
||||
if (prefill.suggestedCategoryName.isNotBlank()) {
|
||||
_pendingCategoryName = prefill.suggestedCategoryName
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Prefill-Fehler ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadItem(itemId: String) {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
viewModelScope.launch {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
|
|
@ -33,7 +34,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -50,6 +50,7 @@ internal fun ItemListScreen(
|
|||
onItemClick: (String) -> Unit,
|
||||
onCategoriesClick: () -> Unit,
|
||||
onLocationsClick: () -> Unit,
|
||||
onCameraClick: () -> Unit,
|
||||
viewModel: ItemListViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -60,6 +61,12 @@ internal fun ItemListScreen(
|
|||
TopAppBar(
|
||||
title = { Text("Artikel") },
|
||||
actions = {
|
||||
IconButton(onClick = onCameraClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PhotoCamera,
|
||||
contentDescription = "Foto erfassen"
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { isMenuExpanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import androidx.navigation.NavHostController
|
|||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import de.krisenvorrat.app.ui.category.CategoryListScreen
|
||||
import de.krisenvorrat.app.ui.camera.CameraScreen
|
||||
import de.krisenvorrat.app.ui.dashboard.DashboardScreen
|
||||
import de.krisenvorrat.app.ui.item.ItemFormScreen
|
||||
import de.krisenvorrat.app.ui.item.ItemListScreen
|
||||
|
|
@ -40,6 +41,9 @@ internal fun KrisenvorratNavGraph(
|
|||
},
|
||||
onLocationsClick = {
|
||||
navController.navigate(Screen.LocationManagement)
|
||||
},
|
||||
onCameraClick = {
|
||||
navController.navigate(Screen.CameraCapture)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -52,6 +56,15 @@ internal fun KrisenvorratNavGraph(
|
|||
)
|
||||
}
|
||||
|
||||
composable<Screen.CameraCapture> {
|
||||
CameraScreen(
|
||||
onNavigateToItemForm = { prefillJson ->
|
||||
navController.navigate(Screen.ItemForm(prefillJson = prefillJson))
|
||||
},
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.CategoryManagement> {
|
||||
CategoryListScreen(
|
||||
onNavigateBack = {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ internal sealed interface Screen {
|
|||
data object ItemList : Screen
|
||||
|
||||
@Serializable
|
||||
data class ItemForm(val itemId: String? = null) : Screen
|
||||
data class ItemForm(val itemId: String? = null, val prefillJson: String? = null) : Screen
|
||||
|
||||
@Serializable
|
||||
data object CameraCapture : Screen
|
||||
|
||||
@Serializable
|
||||
data object CategoryManagement : Screen
|
||||
|
|
|
|||
|
|
@ -315,6 +315,36 @@ internal fun SettingsScreen(
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "KI-Erkennung",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.openAiApiKey,
|
||||
onValueChange = viewModel::onOpenAiApiKeyChanged,
|
||||
label = { Text("OpenAI API-Key") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Wird für die KI-gestützte Foto-Erkennung benötigt.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ internal data class SettingsUiState(
|
|||
val serverUrl: String = "",
|
||||
val apiKey: String = "",
|
||||
val syncStatus: SyncStatus = SyncStatus.Idle,
|
||||
val lastSyncTime: String? = null
|
||||
val lastSyncTime: String? = null,
|
||||
val openAiApiKey: String = ""
|
||||
)
|
||||
|
||||
internal sealed interface SyncStatus {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ internal class SettingsViewModel @Inject constructor(
|
|||
val ageGroups = loadAgeGroupsWithMigration()
|
||||
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
|
||||
val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: ""
|
||||
val openAiApiKey = settingsRepository.getValue(KEY_OPENAI_API_KEY) ?: ""
|
||||
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
|
||||
|
||||
_uiState.update {
|
||||
|
|
@ -56,6 +57,7 @@ internal class SettingsViewModel @Inject constructor(
|
|||
ageGroups = ageGroups,
|
||||
serverUrl = serverUrl,
|
||||
apiKey = apiKey,
|
||||
openAiApiKey = openAiApiKey,
|
||||
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
|
||||
isLoading = false
|
||||
)
|
||||
|
|
@ -123,12 +125,17 @@ internal class SettingsViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(apiKey = value, isSaved = false) }
|
||||
}
|
||||
|
||||
fun onOpenAiApiKeyChanged(value: String) {
|
||||
_uiState.update { it.copy(openAiApiKey = value, isSaved = false) }
|
||||
}
|
||||
|
||||
fun saveSettings() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
|
||||
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
|
||||
settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey)
|
||||
settingsRepository.setValue(KEY_OPENAI_API_KEY, _uiState.value.openAiApiKey)
|
||||
|
||||
_uiState.update { it.copy(isSaved = true) }
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -305,5 +312,6 @@ internal class SettingsViewModel @Inject constructor(
|
|||
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
|
||||
val KEY_API_KEY = SettingsKeys.API_KEY
|
||||
val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP
|
||||
val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,7 @@
|
|||
<cache-path
|
||||
name="exports"
|
||||
path="exports/" />
|
||||
<cache-path
|
||||
name="camera_images"
|
||||
path="camera/" />
|
||||
</paths>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
package de.krisenvorrat.app.ui.camera
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import de.krisenvorrat.app.data.remote.OpenAiVisionService
|
||||
import de.krisenvorrat.app.domain.model.ItemFormPrefill
|
||||
import de.krisenvorrat.app.domain.model.SettingsKeys
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CameraViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private lateinit var mockOpenAiVisionService: OpenAiVisionService
|
||||
private lateinit var mockSettingsRepository: SettingsRepository
|
||||
private lateinit var mockContext: Context
|
||||
private lateinit var mockContentResolver: ContentResolver
|
||||
private lateinit var viewModel: CameraViewModel
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockOpenAiVisionService = mockk()
|
||||
mockSettingsRepository = mockk()
|
||||
mockContext = mockk(relaxed = true)
|
||||
mockContentResolver = mockk(relaxed = true)
|
||||
every { mockContext.contentResolver } returns mockContentResolver
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel(): CameraViewModel =
|
||||
CameraViewModel(mockOpenAiVisionService, mockSettingsRepository, testDispatcher, mockContext)
|
||||
|
||||
@Test
|
||||
fun test_initialState_noPhoto_stateIsEmpty() = runTest(testDispatcher) {
|
||||
// Given
|
||||
coEvery { mockSettingsRepository.getValue(any()) } returns ""
|
||||
viewModel = createViewModel()
|
||||
|
||||
// When
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertNull(state.capturedImageUri)
|
||||
assertFalse(state.isAnalyzing)
|
||||
assertTrue(state.recognizedItems.isEmpty())
|
||||
assertTrue(state.selectedItems.isEmpty())
|
||||
assertNull(state.errorMessage)
|
||||
assertFalse(state.hasApiKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_analyzePhoto_noApiKey_setsErrorMessage() = runTest(testDispatcher) {
|
||||
// Given
|
||||
coEvery { mockSettingsRepository.getValue(any()) } returns ""
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
val mockUri = mockk<Uri>()
|
||||
viewModel.onPhotoCaptured(mockUri)
|
||||
|
||||
// When
|
||||
viewModel.analyzePhoto()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(
|
||||
"Bitte OpenAI API-Key in den Einstellungen hinterlegen",
|
||||
viewModel.uiState.value.errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_toggleItemSelection_selectsItem() = runTest(testDispatcher) {
|
||||
// Given
|
||||
coEvery { mockSettingsRepository.getValue(any()) } returns ""
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.selectedItems.contains(0))
|
||||
|
||||
// When
|
||||
viewModel.toggleItemSelection(0)
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.selectedItems.contains(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_toggleItemSelection_deselectsItem() = runTest(testDispatcher) {
|
||||
// Given
|
||||
coEvery { mockSettingsRepository.getValue(any()) } returns ""
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.toggleItemSelection(1)
|
||||
assertTrue(viewModel.uiState.value.selectedItems.contains(1))
|
||||
|
||||
// When
|
||||
viewModel.toggleItemSelection(1)
|
||||
|
||||
// Then
|
||||
assertFalse(viewModel.uiState.value.selectedItems.contains(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_selectAll_selectsAllItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val items = listOf(
|
||||
ItemFormPrefill(name = "Brot"),
|
||||
ItemFormPrefill(name = "Wasser"),
|
||||
ItemFormPrefill(name = "Konserven")
|
||||
)
|
||||
coEvery { mockSettingsRepository.getValue(any()) } returns "test-api-key"
|
||||
coEvery { mockOpenAiVisionService.analyzeImage(any(), any()) } returns items
|
||||
|
||||
val mockUri = mockk<Uri>()
|
||||
every { mockContentResolver.openInputStream(mockUri) } answers { ByteArrayInputStream(ByteArray(100)) }
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.onPhotoCaptured(mockUri)
|
||||
viewModel.analyzePhoto()
|
||||
advanceUntilIdle()
|
||||
assertEquals(3, viewModel.uiState.value.recognizedItems.size)
|
||||
|
||||
// When
|
||||
viewModel.selectAll()
|
||||
|
||||
// Then
|
||||
assertEquals(setOf(0, 1, 2), viewModel.uiState.value.selectedItems)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_analyzePhoto_apiReturnsItems_updatesRecognizedItems() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val items = listOf(ItemFormPrefill(name = "Brot"), ItemFormPrefill(name = "Wasser"))
|
||||
coEvery { mockSettingsRepository.getValue(any()) } returns "test-api-key"
|
||||
coEvery { mockOpenAiVisionService.analyzeImage(any(), any()) } returns items
|
||||
every { mockContentResolver.openInputStream(any()) } answers { ByteArrayInputStream(ByteArray(100)) }
|
||||
val mockUri = mockk<Uri>()
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.onPhotoCaptured(mockUri)
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.analyzePhoto()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(2, viewModel.uiState.value.recognizedItems.size)
|
||||
assertFalse(viewModel.uiState.value.isAnalyzing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_analyzePhoto_photoNotReadable_setsError() = runTest(testDispatcher) {
|
||||
// Given – onPhotoCaptured was NOT called, capturedImageUri = null
|
||||
coEvery { mockSettingsRepository.getValue(any()) } returns "test-api-key"
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.analyzePhoto()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertNotNull(viewModel.uiState.value.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_onAddFirstSelectedItem_noSelection_doesNotSetNavigation() = runTest(testDispatcher) {
|
||||
// Given – selectedItems is empty
|
||||
coEvery { mockSettingsRepository.getValue(any()) } returns ""
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onAddFirstSelectedItem()
|
||||
|
||||
// Then
|
||||
assertNull(viewModel.uiState.value.navigateToPrefillJson)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
|
|||
import de.krisenvorrat.app.data.db.entity.CategoryEntity
|
||||
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
||||
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
||||
import de.krisenvorrat.app.domain.model.ItemFormPrefill
|
||||
import de.krisenvorrat.app.domain.repository.CategoryRepository
|
||||
import de.krisenvorrat.app.domain.repository.ItemRepository
|
||||
import de.krisenvorrat.app.domain.repository.LocationRepository
|
||||
|
|
@ -16,6 +17,8 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
|||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
|
|
@ -506,6 +509,29 @@ class ItemFormViewModelTest {
|
|||
assertTrue(inserted.id.isNotBlank())
|
||||
assertTrue(fakeItemRepository.updatedItems.isEmpty())
|
||||
}
|
||||
|
||||
// --- Prefill Tests ---
|
||||
|
||||
@Test
|
||||
fun test_applyPrefill_validJson_setsNameAndUnit() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val prefill = ItemFormPrefill(name = "Salz", unit = "kg")
|
||||
val json = Json.encodeToString(ItemFormPrefill.serializer(), prefill)
|
||||
val savedStateHandle = SavedStateHandle().apply { set("prefillJson", json) }
|
||||
val viewModel = ItemFormViewModel(
|
||||
itemRepository = fakeItemRepository,
|
||||
categoryRepository = fakeCategoryRepository,
|
||||
locationRepository = fakeLocationRepository,
|
||||
savedStateHandle = savedStateHandle
|
||||
)
|
||||
|
||||
// When
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals("Salz", viewModel.uiState.value.name)
|
||||
assertEquals("kg", viewModel.uiState.value.unit)
|
||||
}
|
||||
}
|
||||
|
||||
// region Test Fakes
|
||||
|
|
|
|||
Loading…
Reference in a new issue