diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt index a31f00d..edc3090 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt @@ -2,6 +2,8 @@ package de.krisenvorrat.app.ui.settings import android.content.Intent import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -19,6 +22,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField 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 @@ -38,6 +42,12 @@ internal fun SettingsScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { viewModel.onImportFileSelected(it) } + } + LaunchedEffect(uiState.shareContent) { val content = uiState.shareContent ?: return@LaunchedEffect val shareIntent = when (content) { @@ -158,7 +168,17 @@ internal fun SettingsScreen( Text("Als Markdown teilen") } - if (uiState.isExporting) { + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = { filePickerLauncher.launch(arrayOf("application/json")) }, + enabled = !uiState.isImporting, + modifier = Modifier.fillMaxWidth() + ) { + Text("Daten importieren") + } + + if (uiState.isExporting || uiState.isImporting) { Spacer(modifier = Modifier.height(8.dp)) CircularProgressIndicator( modifier = Modifier.padding(8.dp) @@ -167,5 +187,51 @@ internal fun SettingsScreen( } } } + + if (uiState.pendingImportUri != null) { + AlertDialog( + onDismissRequest = viewModel::onImportDismissed, + title = { Text("Daten importieren?") }, + text = { Text("Bestehende Daten werden durch den Import überschrieben. Möchten Sie fortfahren?") }, + confirmButton = { + TextButton(onClick = viewModel::onImportConfirmed) { + Text("Importieren") + } + }, + dismissButton = { + TextButton(onClick = viewModel::onImportDismissed) { + Text("Abbrechen") + } + } + ) + } + + val importResult = uiState.importResult + if (importResult != null) { + AlertDialog( + onDismissRequest = viewModel::onImportResultDismissed, + title = { + Text( + when (importResult) { + is ImportResult.Success -> "Import erfolgreich" + is ImportResult.Error -> "Import fehlgeschlagen" + } + ) + }, + text = { + Text( + when (importResult) { + is ImportResult.Success -> "Daten wurden erfolgreich importiert." + is ImportResult.Error -> importResult.message + } + ) + }, + confirmButton = { + TextButton(onClick = viewModel::onImportResultDismissed) { + Text("OK") + } + } + ) + } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt index 6e12e27..4282450 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt @@ -1,5 +1,7 @@ package de.krisenvorrat.app.ui.settings +import android.net.Uri + internal data class SettingsUiState( val householdSize: String = "", val dailyKcalPerPerson: String = "", @@ -7,5 +9,13 @@ internal data class SettingsUiState( val isSaved: Boolean = false, val isExporting: Boolean = false, val shareContent: ShareContent? = null, - val exportError: String? = null + val exportError: String? = null, + val isImporting: Boolean = false, + val importResult: ImportResult? = null, + val pendingImportUri: Uri? = null ) + +internal sealed interface ImportResult { + data object Success : ImportResult + data class Error(val message: String) : ImportResult +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt index 7414e80..24f24a0 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt @@ -1,6 +1,7 @@ package de.krisenvorrat.app.ui.settings import android.content.Context +import android.net.Uri import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -127,6 +128,44 @@ internal class SettingsViewModel @Inject constructor( _uiState.update { it.copy(exportError = null) } } + fun onImportFileSelected(uri: Uri) { + _uiState.update { it.copy(pendingImportUri = uri) } + } + + fun onImportConfirmed() { + val uri = _uiState.value.pendingImportUri ?: return + _uiState.update { it.copy(pendingImportUri = null, isImporting = true, importResult = null) } + viewModelScope.launch { + try { + val json = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } + if (json.isNullOrBlank()) { + _uiState.update { it.copy(isImporting = false, importResult = ImportResult.Error("Datei ist leer oder konnte nicht gelesen werden")) } + return@launch + } + val result = importExportRepository.importFromJson(json) + result.fold( + onSuccess = { + loadSettings() + _uiState.update { it.copy(isImporting = false, importResult = ImportResult.Success) } + }, + onFailure = { e -> + _uiState.update { it.copy(isImporting = false, importResult = ImportResult.Error(e.message ?: "Import fehlgeschlagen")) } + } + ) + } catch (e: Exception) { + _uiState.update { it.copy(isImporting = false, importResult = ImportResult.Error("Datei konnte nicht gelesen werden")) } + } + } + } + + fun onImportDismissed() { + _uiState.update { it.copy(pendingImportUri = null) } + } + + fun onImportResultDismissed() { + _uiState.update { it.copy(importResult = null) } + } + companion object { const val KEY_HOUSEHOLD_SIZE = "household_size" const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person" diff --git a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt index 04154af..f94d0ad 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt @@ -2,6 +2,8 @@ package de.krisenvorrat.app.ui.settings import android.content.Context import androidx.core.content.FileProvider +import android.content.ContentResolver +import android.net.Uri import de.krisenvorrat.app.data.db.entity.SettingsEntity import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.SettingsRepository @@ -27,6 +29,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.io.ByteArrayInputStream import java.io.File @OptIn(ExperimentalCoroutinesApi::class) @@ -305,6 +308,144 @@ class SettingsViewModelTest { // Then assertNull(viewModel.uiState.value.exportError) } + + @Test + fun test_onImportFileSelected_setsPendingUri() = runTest(testDispatcher) { + // Given + viewModel = createViewModel() + advanceUntilIdle() + val mockUri = mockk() + + // When + viewModel.onImportFileSelected(mockUri) + + // Then + assertEquals(mockUri, viewModel.uiState.value.pendingImportUri) + } + + @Test + fun test_onImportDismissed_clearsPendingUri() = runTest(testDispatcher) { + // Given + viewModel = createViewModel() + advanceUntilIdle() + val mockUri = mockk() + viewModel.onImportFileSelected(mockUri) + + // When + viewModel.onImportDismissed() + + // Then + assertNull(viewModel.uiState.value.pendingImportUri) + } + + @Test + fun test_onImportConfirmed_success_setsSuccessResult() = runTest(testDispatcher) { + // Given + val mockUri = mockk() + val mockContentResolver = mockk() + val jsonContent = """{"version":1,"categories":[],"locations":[],"items":[],"settings":[]}""" + every { mockContext.contentResolver } returns mockContentResolver + every { mockContentResolver.openInputStream(mockUri) } returns ByteArrayInputStream(jsonContent.toByteArray()) + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onImportFileSelected(mockUri) + + // When + viewModel.onImportConfirmed() + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isImporting) + assertNull(state.pendingImportUri) + assertTrue(state.importResult is ImportResult.Success) + } + + @Test + fun test_onImportConfirmed_invalidJson_setsErrorResult() = runTest(testDispatcher) { + // Given + val mockUri = mockk() + val mockContentResolver = mockk() + every { mockContext.contentResolver } returns mockContentResolver + every { mockContentResolver.openInputStream(mockUri) } returns ByteArrayInputStream("{ not valid }".toByteArray()) + fakeImportExportRepository.importShouldFail = true + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onImportFileSelected(mockUri) + + // When + viewModel.onImportConfirmed() + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isImporting) + assertTrue(state.importResult is ImportResult.Error) + } + + @Test + fun test_onImportConfirmed_emptyFile_setsErrorResult() = runTest(testDispatcher) { + // Given + val mockUri = mockk() + val mockContentResolver = mockk() + every { mockContext.contentResolver } returns mockContentResolver + every { mockContentResolver.openInputStream(mockUri) } returns ByteArrayInputStream(ByteArray(0)) + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onImportFileSelected(mockUri) + + // When + viewModel.onImportConfirmed() + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isImporting) + assertTrue(state.importResult is ImportResult.Error) + assertEquals("Datei ist leer oder konnte nicht gelesen werden", (state.importResult as ImportResult.Error).message) + } + + @Test + fun test_onImportConfirmed_nullInputStream_setsErrorResult() = runTest(testDispatcher) { + // Given + val mockUri = mockk() + val mockContentResolver = mockk() + every { mockContext.contentResolver } returns mockContentResolver + every { mockContentResolver.openInputStream(mockUri) } returns null + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onImportFileSelected(mockUri) + + // When + viewModel.onImportConfirmed() + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertFalse(state.isImporting) + assertTrue(state.importResult is ImportResult.Error) + } + + @Test + fun test_onImportResultDismissed_clearsImportResult() = runTest(testDispatcher) { + // Given + val mockUri = mockk() + val mockContentResolver = mockk() + val jsonContent = """{"version":1,"categories":[],"locations":[],"items":[],"settings":[]}""" + every { mockContext.contentResolver } returns mockContentResolver + every { mockContentResolver.openInputStream(mockUri) } returns ByteArrayInputStream(jsonContent.toByteArray()) + viewModel = createViewModel() + advanceUntilIdle() + viewModel.onImportFileSelected(mockUri) + viewModel.onImportConfirmed() + advanceUntilIdle() + + // When + viewModel.onImportResultDismissed() + + // Then + assertNull(viewModel.uiState.value.importResult) + } } // region Test Helpers @@ -330,6 +471,7 @@ private class FakeImportExportRepository : ImportExportRepository { var jsonResult = """{"version":1,"categories":[],"locations":[],"items":[],"settings":[]}""" var markdownResult = "# Krisenvorrat Inventar\n" var shouldThrow = false + var importShouldFail = false override suspend fun exportToJson(): String { if (shouldThrow) throw RuntimeException("Test error") @@ -337,7 +479,7 @@ private class FakeImportExportRepository : ImportExportRepository { } override suspend fun importFromJson(json: String): Result { - if (shouldThrow) return Result.failure(RuntimeException("Test error")) + if (shouldThrow || importShouldFail) return Result.failure(RuntimeException("Test error")) return Result.success(Unit) }