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:
Jens Reinemann 2026-05-16 17:58:08 +02:00
parent f4b5197b06
commit dd571f46fc
18 changed files with 896 additions and 3 deletions

View file

@ -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."
}
}

View file

@ -0,0 +1,7 @@
package de.krisenvorrat.app.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class IoDispatcher

View file

@ -5,12 +5,16 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent 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.data.sync.SyncServiceImpl
import de.krisenvorrat.app.domain.repository.SyncService import de.krisenvorrat.app.domain.repository.SyncService
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@ -23,6 +27,9 @@ internal abstract class NetworkModule {
@Singleton @Singleton
abstract fun bindSyncService(impl: SyncServiceImpl): SyncService abstract fun bindSyncService(impl: SyncServiceImpl): SyncService
@Binds
abstract fun bindOpenAiVisionService(impl: OpenAiVisionServiceImpl): OpenAiVisionService
companion object { companion object {
@Provides @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 CONNECT_TIMEOUT_SECONDS = 10L
private const val READ_TIMEOUT_SECONDS = 30L private const val READ_TIMEOUT_SECONDS = 30L
private const val WRITE_TIMEOUT_SECONDS = 30L private const val WRITE_TIMEOUT_SECONDS = 30L

View file

@ -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 = ""
)

View file

@ -7,4 +7,5 @@ internal object SettingsKeys {
const val SERVER_URL = "server_url" const val SERVER_URL = "server_url"
const val API_KEY = "api_key" const val API_KEY = "api_key"
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp" const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
const val OPENAI_API_KEY = "openai_api_key"
} }

View 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
)
}
}
}
}

View file

@ -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
)

View file

@ -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) }
}
}

View file

@ -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.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.LocationRepository import de.krisenvorrat.app.domain.repository.LocationRepository
import de.krisenvorrat.app.domain.model.ItemFormPrefill
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -51,11 +53,16 @@ internal class ItemFormViewModel @Inject constructor(
val uiState: StateFlow<ItemFormUiState> = _uiState.asStateFlow() val uiState: StateFlow<ItemFormUiState> = _uiState.asStateFlow()
private val editItemId: String? = savedStateHandle.get<String>("itemId") 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 { init {
loadDropdownData() loadDropdownData()
if (editItemId != null) { if (editItemId != null) {
loadItem(editItemId) loadItem(editItemId)
} else if (prefillJson != null) {
applyPrefill(prefillJson)
} }
} }
@ -63,6 +70,14 @@ internal class ItemFormViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
categoryRepository.getAll().collect { categories -> categoryRepository.getAll().collect { categories ->
_uiState.update { it.copy(categories = 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 { 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) { private fun loadItem(itemId: String) {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
viewModelScope.launch { viewModelScope.launch {

View file

@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@ -33,7 +34,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -50,6 +50,7 @@ internal fun ItemListScreen(
onItemClick: (String) -> Unit, onItemClick: (String) -> Unit,
onCategoriesClick: () -> Unit, onCategoriesClick: () -> Unit,
onLocationsClick: () -> Unit, onLocationsClick: () -> Unit,
onCameraClick: () -> Unit,
viewModel: ItemListViewModel = hiltViewModel() viewModel: ItemListViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -60,6 +61,12 @@ internal fun ItemListScreen(
TopAppBar( TopAppBar(
title = { Text("Artikel") }, title = { Text("Artikel") },
actions = { actions = {
IconButton(onClick = onCameraClick) {
Icon(
imageVector = Icons.Default.PhotoCamera,
contentDescription = "Foto erfassen"
)
}
IconButton(onClick = { isMenuExpanded = true }) { IconButton(onClick = { isMenuExpanded = true }) {
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,

View file

@ -6,6 +6,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import de.krisenvorrat.app.ui.category.CategoryListScreen 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.dashboard.DashboardScreen
import de.krisenvorrat.app.ui.item.ItemFormScreen import de.krisenvorrat.app.ui.item.ItemFormScreen
import de.krisenvorrat.app.ui.item.ItemListScreen import de.krisenvorrat.app.ui.item.ItemListScreen
@ -40,6 +41,9 @@ internal fun KrisenvorratNavGraph(
}, },
onLocationsClick = { onLocationsClick = {
navController.navigate(Screen.LocationManagement) 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> { composable<Screen.CategoryManagement> {
CategoryListScreen( CategoryListScreen(
onNavigateBack = { onNavigateBack = {

View file

@ -12,7 +12,10 @@ internal sealed interface Screen {
data object ItemList : Screen data object ItemList : Screen
@Serializable @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 @Serializable
data object CategoryManagement : Screen data object CategoryManagement : Screen

View file

@ -315,6 +315,36 @@ internal fun SettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant 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
)
} }
} }
} }

View file

@ -17,7 +17,8 @@ internal data class SettingsUiState(
val serverUrl: String = "", val serverUrl: String = "",
val apiKey: String = "", val apiKey: String = "",
val syncStatus: SyncStatus = SyncStatus.Idle, val syncStatus: SyncStatus = SyncStatus.Idle,
val lastSyncTime: String? = null val lastSyncTime: String? = null,
val openAiApiKey: String = ""
) )
internal sealed interface SyncStatus { internal sealed interface SyncStatus {

View file

@ -49,6 +49,7 @@ internal class SettingsViewModel @Inject constructor(
val ageGroups = loadAgeGroupsWithMigration() val ageGroups = loadAgeGroupsWithMigration()
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: "" val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: "" val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: ""
val openAiApiKey = settingsRepository.getValue(KEY_OPENAI_API_KEY) ?: ""
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP) val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
_uiState.update { _uiState.update {
@ -56,6 +57,7 @@ internal class SettingsViewModel @Inject constructor(
ageGroups = ageGroups, ageGroups = ageGroups,
serverUrl = serverUrl, serverUrl = serverUrl,
apiKey = apiKey, apiKey = apiKey,
openAiApiKey = openAiApiKey,
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) }, lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
isLoading = false isLoading = false
) )
@ -123,12 +125,17 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(apiKey = value, isSaved = false) } _uiState.update { it.copy(apiKey = value, isSaved = false) }
} }
fun onOpenAiApiKeyChanged(value: String) {
_uiState.update { it.copy(openAiApiKey = value, isSaved = false) }
}
fun saveSettings() { fun saveSettings() {
viewModelScope.launch { viewModelScope.launch {
try { try {
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson()) settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl) settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey) settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey)
settingsRepository.setValue(KEY_OPENAI_API_KEY, _uiState.value.openAiApiKey)
_uiState.update { it.copy(isSaved = true) } _uiState.update { it.copy(isSaved = true) }
} catch (e: Exception) { } catch (e: Exception) {
@ -305,5 +312,6 @@ internal class SettingsViewModel @Inject constructor(
val KEY_SERVER_URL = SettingsKeys.SERVER_URL val KEY_SERVER_URL = SettingsKeys.SERVER_URL
val KEY_API_KEY = SettingsKeys.API_KEY val KEY_API_KEY = SettingsKeys.API_KEY
val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP
val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY
} }
} }

View file

@ -3,4 +3,7 @@
<cache-path <cache-path
name="exports" name="exports"
path="exports/" /> path="exports/" />
<cache-path
name="camera_images"
path="camera/" />
</paths> </paths>

View file

@ -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)
}
}

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import de.krisenvorrat.app.data.db.entity.CategoryEntity import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity 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.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.LocationRepository 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.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@ -506,6 +509,29 @@ class ItemFormViewModelTest {
assertTrue(inserted.id.isNotBlank()) assertTrue(inserted.id.isNotBlank())
assertTrue(fakeItemRepository.updatedItems.isEmpty()) 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 // region Test Fakes