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.content.Intent
import android.widget.Toast 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -19,6 +22,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -38,6 +42,12 @@ internal fun SettingsScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.onImportFileSelected(it) }
}
LaunchedEffect(uiState.shareContent) { LaunchedEffect(uiState.shareContent) {
val content = uiState.shareContent ?: return@LaunchedEffect val content = uiState.shareContent ?: return@LaunchedEffect
val shareIntent = when (content) { val shareIntent = when (content) {
@ -158,7 +168,17 @@ internal fun SettingsScreen(
Text("Als Markdown teilen") 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)) Spacer(modifier = Modifier.height(8.dp))
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.padding(8.dp) 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 package de.krisenvorrat.app.ui.settings
import android.net.Uri
internal data class SettingsUiState( internal data class SettingsUiState(
val householdSize: String = "", val householdSize: String = "",
val dailyKcalPerPerson: String = "", val dailyKcalPerPerson: String = "",
@ -7,5 +9,13 @@ internal data class SettingsUiState(
val isSaved: Boolean = false, val isSaved: Boolean = false,
val isExporting: Boolean = false, val isExporting: Boolean = false,
val shareContent: ShareContent? = null, 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 package de.krisenvorrat.app.ui.settings
import android.content.Context import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -127,6 +128,44 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(exportError = null) } _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 { companion object {
const val KEY_HOUSEHOLD_SIZE = "household_size" const val KEY_HOUSEHOLD_SIZE = "household_size"
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person" 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 android.content.Context
import androidx.core.content.FileProvider 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.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SettingsRepository
@ -27,6 +29,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -305,6 +308,144 @@ class SettingsViewModelTest {
// Then // Then
assertNull(viewModel.uiState.value.exportError) 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 // region Test Helpers
@ -330,6 +471,7 @@ private class FakeImportExportRepository : ImportExportRepository {
var jsonResult = """{"version":1,"categories":[],"locations":[],"items":[],"settings":[]}""" var jsonResult = """{"version":1,"categories":[],"locations":[],"items":[],"settings":[]}"""
var markdownResult = "# Krisenvorrat Inventar\n" var markdownResult = "# Krisenvorrat Inventar\n"
var shouldThrow = false var shouldThrow = false
var importShouldFail = false
override suspend fun exportToJson(): String { override suspend fun exportToJson(): String {
if (shouldThrow) throw RuntimeException("Test error") if (shouldThrow) throw RuntimeException("Test error")
@ -337,7 +479,7 @@ private class FakeImportExportRepository : ImportExportRepository {
} }
override suspend fun importFromJson(json: String): Result<Unit> { 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) return Result.success(Unit)
} }