feat(settings): add JSON import with SAF file picker

SettingsScreen: Added import button that opens the system file picker
(ActivityResultContracts.OpenDocument) filtered to application/json.
After file selection, a confirmation dialog warns that existing data
will be overwritten. Import result is shown in a success/error dialog.

SettingsViewModel: Added onImportFileSelected(uri), onImportConfirmed(),
onImportDismissed(), onImportResultDismissed() methods. The import reads
the file via contentResolver.openInputStream() and delegates to the
existing ImportExportRepository.importFromJson(). Settings are reloaded
after successful import.

SettingsUiState: Extended with isImporting, importResult (sealed
interface ImportResult with Success/Error), and pendingImportUri for
the confirmation dialog flow.

SettingsViewModelTest: Added 6 unit tests covering import success,
invalid JSON error, empty file, null input stream, dialog state
management, and result dismissal.

Closes #38
This commit is contained in:
Jens Reinemann 2026-05-14 03:35:50 +02:00
parent 8193445939
commit ce851af37b
4 changed files with 260 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -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<Uri>()
// 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<Uri>()
viewModel.onImportFileSelected(mockUri)
// When
viewModel.onImportDismissed()
// Then
assertNull(viewModel.uiState.value.pendingImportUri)
}
@Test
fun test_onImportConfirmed_success_setsSuccessResult() = runTest(testDispatcher) {
// Given
val mockUri = mockk<Uri>()
val mockContentResolver = mockk<ContentResolver>()
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<Uri>()
val mockContentResolver = mockk<ContentResolver>()
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<Uri>()
val mockContentResolver = mockk<ContentResolver>()
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<Uri>()
val mockContentResolver = mockk<ContentResolver>()
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<Uri>()
val mockContentResolver = mockk<ContentResolver>()
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<Unit> {
if (shouldThrow) return Result.failure(RuntimeException("Test error"))
if (shouldThrow || importShouldFail) return Result.failure(RuntimeException("Test error"))
return Result.success(Unit)
}