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:
parent
8193445939
commit
ce851af37b
4 changed files with 260 additions and 3 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue