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.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue